mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Merge origin/main into brennanb2025/agent-status-preserve-restart
Resolves the agent-hooks refactor collision from #1678 (shared listener + relay adapter). The refactor extracted listener internals into `src/shared/agent-hook-listener.ts` and replaced server.ts's module-level `lastStatusByPaneKey` Map with `state.lastStatusByPaneKey` on a shared `HookListenerState`. This branch's persistence layer is re-anchored on the new shape: - `AgentStatusIpcPayload` now carries `connectionId: string | null` (from main) alongside `receivedAt` / `stateStartedAt` (from this branch). - `src/main/agent-hooks/server.ts` rewritten as the slim adapter (~720 LoC) over the shared listener: defines a server-process-only `EnrichedAgentHookEventPayload = AgentHookEventPayload & {receivedAt, stateStartedAt}` stored in `state.lastStatusByPaneKey` (the shared module never reads this map, so the extra fields ride along untouched), keeps `last-status.json` v2 hydrate / sanitize / TTL / atomic-write / drop semantics. The new HTTP and `ingestRemote` paths both run through `attachStatusTiming` before caching. - `src/main/index.ts` IPC fanout forwards the union: connectionId + receivedAt + stateStartedAt + ...payload. - `src/preload/{api-types,index}.ts` keep the typed `AgentStatusIpcPayload` surface (which now subsumes both branches' fields). - `src/main/agent-hooks/server.test.ts`: persistence tests preserved as-is; ingestRemote tests from main re-laxed from `toHaveBeenCalledWith({...})` to `expect.objectContaining({...})` so the listener's enriched payload doesn't fail strict equality. Verified: pnpm tc:node + tc:web clean; 776/776 vitest tests pass across agent-hooks, shared listener, relay, IPC handler, and renderer agent-status slice + ui slice. tc:cli has pre-existing TS6307 errors on origin/main that are not introduced by this merge. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+2
-3
@@ -67,9 +67,8 @@ design-docs/
|
||||
.atl/
|
||||
|
||||
# Local-only design/planning docs (not checked in)
|
||||
docs/*-design.md
|
||||
docs/*-plan.md
|
||||
docs/design-*.md
|
||||
docs/*.md
|
||||
!docs/README*.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# GitHub Work Item Drawer: Fix Reopen Flash
|
||||
|
||||
## Symptom
|
||||
|
||||
After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears.
|
||||
|
||||
## Verified root cause
|
||||
|
||||
`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`).
|
||||
|
||||
Current sequence on close/reopen of the same item:
|
||||
|
||||
1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`.
|
||||
2. Reopen renders once with `details === null`.
|
||||
3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`.
|
||||
4. Second render shows cached content.
|
||||
|
||||
With sheet animation, that first empty paint is visible as a flash.
|
||||
|
||||
## Refactor direction
|
||||
|
||||
Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render.
|
||||
|
||||
This is not optional if we want reopen to paint cached content on first render.
|
||||
|
||||
## Required cache-store changes
|
||||
|
||||
Add a store subscription layer around `workItemDetailsCache`:
|
||||
|
||||
- `subscribeWorkItemDetailsCache(listener)`
|
||||
- `notifyWorkItemDetailsCache()`
|
||||
|
||||
Call `notifyWorkItemDetailsCache()` after every actual cache mutation:
|
||||
|
||||
- `touchWorkItemDetailsCache`
|
||||
- `invalidateWorkItemDetailsCacheForKey`
|
||||
- `invalidateWorkItemDetailsCacheByMatch`
|
||||
|
||||
Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data.
|
||||
|
||||
## `useSyncExternalStore` contract details
|
||||
|
||||
Use:
|
||||
|
||||
```ts
|
||||
const cachedEntry = useSyncExternalStore(
|
||||
subscribeWorkItemDetailsCache,
|
||||
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined)
|
||||
)
|
||||
```
|
||||
|
||||
Important constraints:
|
||||
|
||||
- `getSnapshot` must return a referentially stable value when store data has not changed.
|
||||
- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object).
|
||||
- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops).
|
||||
|
||||
## Component state changes
|
||||
|
||||
Remove component-local `details/loading/error` state for work-item details.
|
||||
|
||||
Derive view state from `cachedEntry`:
|
||||
|
||||
- `details = cachedEntry?.details ?? null`
|
||||
- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details`
|
||||
- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null`
|
||||
|
||||
Keep stale-on-error behavior by preserving `details` in cache while setting `error`.
|
||||
|
||||
## Effect responsibilities after refactor
|
||||
|
||||
The main effect should:
|
||||
|
||||
- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`)
|
||||
- decide freshness and in-flight dedupe
|
||||
- launch fetch and write results/errors/pending back into cache
|
||||
|
||||
The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details.
|
||||
|
||||
## Optimistic comment race and merge rules
|
||||
|
||||
`appendOptimisticComment` must write through cache only (no local `setDetails`).
|
||||
|
||||
Rules:
|
||||
|
||||
- push comment into `optimisticCommentsRef`
|
||||
- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify
|
||||
- derive rendered comments from cached details plus optimistic refs
|
||||
|
||||
Dependency pitfall:
|
||||
|
||||
- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo`
|
||||
- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway
|
||||
|
||||
This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps.
|
||||
|
||||
## Invalidation while drawer is open
|
||||
|
||||
When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted:
|
||||
|
||||
- subscription notifies immediately
|
||||
- render sees `cachedEntry === undefined`
|
||||
- UI falls back to loading/empty state and effect triggers refetch
|
||||
|
||||
This is expected and preferred over showing stale state.
|
||||
|
||||
## Fetch race constraints (must keep)
|
||||
|
||||
Keep current protections:
|
||||
|
||||
- per-key in-flight dedupe via `entry.pending`
|
||||
- generation guard to block stale writes after invalidation
|
||||
- stale-on-null handling (`result === null` must not wipe existing valid cached details)
|
||||
|
||||
Add one missing consistency rule:
|
||||
|
||||
- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation.
|
||||
|
||||
## Scope of deletion
|
||||
|
||||
Delete only the work-item-detail local state and call sites:
|
||||
|
||||
- `useState<GitHubWorkItemDetails | null>` (`details`)
|
||||
- `useState<boolean>` (`loading`) for this data path
|
||||
- `useState<string | null>` (`error`) for this data path
|
||||
- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path
|
||||
|
||||
Do not touch unrelated loading/error state in other subviews.
|
||||
|
||||
## Expected result
|
||||
|
||||
Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Auto-refresh on entering the Checks tab
|
||||
|
||||
## Problem
|
||||
|
||||
The Checks panel fetches on visibility, but those fetches are cache-respecting:
|
||||
|
||||
- `CACHE_TTL = 300_000` for PRs and comments.
|
||||
- `CHECKS_CACHE_TTL = 60_000` for checks.
|
||||
- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`.
|
||||
- Checks polling and the comments effect also call the store without `force`.
|
||||
|
||||
That means entering the Checks tab can render stale PR metadata, a cached `null`
|
||||
"no PR" result, stale comments, or stale checks. The checks path is partially
|
||||
protected by a shorter renderer TTL, but the main process also uses `gh api
|
||||
--cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh
|
||||
already bypasses these renderer and `gh api` caches for the requests it starts.
|
||||
|
||||
## Goal
|
||||
|
||||
When the user enters the Checks tab, run one freshness check for the active
|
||||
worktree and force-refresh only when the relevant cache timestamps are older
|
||||
than a small grace window. "Entering" means:
|
||||
|
||||
- opening the right sidebar while the Checks tab is selected;
|
||||
- switching from another right-sidebar tab to Checks;
|
||||
- switching active worktree/repo/branch while Checks is already visible.
|
||||
|
||||
The refresh must cover PR discovery too, including cached `null`, so a PR opened
|
||||
outside Orca can appear without waiting up to 5 minutes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the existing polling cadence while the panel stays visible.
|
||||
- Refreshing on window focus, network reconnect, or arbitrary background events.
|
||||
- Changing the manual refresh button.
|
||||
- Prefetching checks for non-active worktrees.
|
||||
- Adding cross-renderer or cross-window request coordination. Orca currently has
|
||||
one live main window; renderer in-flight maps are not a multi-window primitive.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by:
|
||||
|
||||
```ts
|
||||
const entryKey = isPanelVisible && repo && !isFolder && branch
|
||||
? `${activeWorktreeId ?? ''}::${repo.path}::${branch}`
|
||||
: ''
|
||||
```
|
||||
|
||||
Track the last processed visible `entryKey` in a ref. When `entryKey` is empty,
|
||||
reset the ref to `''`. When it becomes non-empty and differs from the ref, this
|
||||
is an entry. This is required; a `prevKey !== currentKey` check that does not
|
||||
reset on hide would miss closing and reopening the same PR.
|
||||
|
||||
2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in
|
||||
`ChecksPanel.tsx`. Select only timestamps, not whole cache records:
|
||||
|
||||
```ts
|
||||
const prFetchedAt = useAppStore(
|
||||
(s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined)
|
||||
)
|
||||
const checksFetchedAt = useAppStore(
|
||||
(s) => prNumber
|
||||
? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt
|
||||
: undefined
|
||||
)
|
||||
const commentsFetchedAt = useAppStore(
|
||||
(s) => prNumber
|
||||
? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt
|
||||
: undefined
|
||||
)
|
||||
```
|
||||
|
||||
Missing PR cache is stale. A cached PR value of `null` is still a PR cache
|
||||
entry and should be refreshed on entry once outside the grace window. When a
|
||||
PR number is known, missing checks/comments timestamps are stale. When no PR is
|
||||
known, checks/comments are not relevant yet.
|
||||
|
||||
Run `handleRefresh()` if the oldest relevant timestamp is missing or older
|
||||
than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip.
|
||||
|
||||
3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The
|
||||
manual refresh flow is the right shape: force `fetchPRForBranch`, then if a
|
||||
PR is returned, force checks and comments for the returned PR. Extend
|
||||
`fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks`
|
||||
directly from `handleRefresh`) so the checks request uses
|
||||
`refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR
|
||||
refresh completed. This handles PR number changes, cached `null`, and
|
||||
external force-pushes correctly.
|
||||
|
||||
4. **Reset polling attention state before refresh.** When the entry refresh runs,
|
||||
set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before
|
||||
calling `handleRefresh()`. `fetchChecks()` will then write the new signature
|
||||
from the forced result.
|
||||
|
||||
5. **Do not overstate in-flight behavior.** Current store behavior is:
|
||||
|
||||
- `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR
|
||||
request and uses a generation guard so the older result cannot overwrite the
|
||||
newer cache entry.
|
||||
- `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do
|
||||
**not** bypass any in-flight request for the same key. If a non-forced poll
|
||||
is already in flight, entry refresh will join it and may not pass
|
||||
`noCache: true` to the main process.
|
||||
|
||||
Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a
|
||||
visible polling race. If strict "entry always bypasses gh cache" semantics are
|
||||
required later, change checks/comments in-flight maps to track `{ promise,
|
||||
force, generation }` like PRs.
|
||||
|
||||
## API cost and feasibility
|
||||
|
||||
This is not free. A cold entry refresh can start:
|
||||
|
||||
- one PR lookup (`gh:prForBranch`);
|
||||
- one checks request (`gh:prChecks`);
|
||||
- one comments request (`gh:prComments`).
|
||||
|
||||
`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs`
|
||||
and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr
|
||||
checks`, which does not use the `--cache` flag.
|
||||
|
||||
`gh:prComments` is heavier than "one GitHub API call": it runs issue comments
|
||||
REST, review threads GraphQL, and reviews REST in parallel. `noCache` only
|
||||
removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always
|
||||
made.
|
||||
|
||||
The 15 s grace window is therefore required, not cosmetic.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh
|
||||
the PR cache entry on tab entry when its timestamp is outside the grace window.
|
||||
- **First Checks entry after app start.** Only `prCache` and `issueCache` are
|
||||
persisted. `checksCache` and `commentsCache` start empty, so a known PR should
|
||||
force checks/comments on entry.
|
||||
- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the
|
||||
entry key so same repo/branch switches still count as a new entry. The existing
|
||||
render-time local reset handles stale title/loading state.
|
||||
- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing
|
||||
it again re-evaluates timestamps. The grace window suppresses duplicate calls.
|
||||
- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll
|
||||
request instead of forcing a new `noCache` request. This is intentional for now.
|
||||
- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect
|
||||
can still force a PR refresh. Entry refresh may also run, but PR generation
|
||||
guards prevent older PR responses from overwriting newer cache entries.
|
||||
- **PR head changed externally.** `handleRefresh()` must use the refreshed PR
|
||||
returned by `fetchPRForBranch` before fetching checks, so checks are requested
|
||||
with the current `headSha`.
|
||||
- **Component unmount mid-fetch.** The comments auto-fetch guards stale
|
||||
responses; the checks fetch path and `handleRefresh()` do not cancel local
|
||||
setters. This feature should keep that behavior unless tests expose a warning
|
||||
or stale-state regression.
|
||||
- **SSH / remote repo.** No path manipulation or platform-specific shortcut code
|
||||
is needed. The same IPC-backed `gh` path is used.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in
|
||||
`ChecksPanel.tsx`.
|
||||
2. Keep `handleRefresh()` as the single refresh implementation.
|
||||
3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes,
|
||||
fresh-within-grace skips, known PR with missing checks/comments refreshes,
|
||||
hidden panel no-op, and hide/show same PR re-evaluation.
|
||||
4. Run `pnpm typecheck && pnpm lint`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Sidebar Repo Filter Redesign
|
||||
|
||||
## Problem
|
||||
|
||||
The current sidebar filter UI does not scale beyond a small repo count. Rendering every repo as a checkbox row in a menu creates poor scanability, high click cost, and clipping/scroll friction.
|
||||
|
||||
Constraints from current code:
|
||||
- Filter semantics are store-driven (`showActiveOnly`, `hideDefaultBranchWorkspace`, `filterRepoIds`) and consumed by `computeVisibleWorktreeIds`.
|
||||
- `searchRepos()` already provides ranking (display name first, path fallback).
|
||||
- The trigger badge is the primary “filters active” affordance and must stay.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Make repo filtering usable with large repo sets.
|
||||
2. Keep toggle filters one click.
|
||||
3. Add fast bulk actions (`All`, `None`, `Clear all`).
|
||||
4. Preserve existing filter semantics and badge behavior.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No semantic changes to filtering logic in `visible-worktrees.ts`.
|
||||
- No sticky-all persistence model like `RepoMultiCombobox`.
|
||||
- No store schema migration.
|
||||
|
||||
## Correctness Notes (from code audit)
|
||||
|
||||
- `searchRepos()` is not debounced; it is synchronous `useMemo` filtering.
|
||||
- Popover wheel rescue in `popover.tsx` only runs when the wheel handler is attached to a `PopoverContent` element that has class `popover-scroll-content`. Putting that class on an inner div does not activate the rescue path.
|
||||
- `command.tsx` already includes a wheel fallback on `CommandList` for scroll-locked Radix Dialog parents.
|
||||
- Sidebar filters are persisted via `window.api.ui.set` (debounced in `App.tsx`) and restored from persisted UI on launch.
|
||||
|
||||
## Proposed UI
|
||||
|
||||
Use `Popover` and keep trigger button/badge unchanged.
|
||||
|
||||
Popover sections:
|
||||
1. Header: `Filters` + `Clear all` (shown only when any filter is active).
|
||||
2. Toggle rows: `Active only`, `Hide default branch`.
|
||||
3. Repo section (only when `repos.length > 1`):
|
||||
- Label + selected count.
|
||||
- `All` and `None` actions.
|
||||
- Search box (`Search repos...`).
|
||||
- Scrollable repo list with checkmark, repo dot/name, and SSH badge when `connectionId` exists.
|
||||
- Secondary path line for disambiguation when names collide.
|
||||
4. Bottom action: `Add project` pinned below repo list.
|
||||
|
||||
## Implementation Decision: cmdk vs plain input
|
||||
|
||||
Use `Command` primitives (as in `repo-multi-combobox.tsx`) with `shouldFilter={false}` and feed pre-ranked `searchRepos()` results.
|
||||
|
||||
Why:
|
||||
- Better keyboard behavior out of the box (arrow navigation, enter selection).
|
||||
- Existing `CommandList` wheel fallback handles scroll-lock contexts that currently break plain inner-scroll regions.
|
||||
- Consistent behavior with an existing repo selector pattern in the app.
|
||||
|
||||
## Edge Cases / Invalidation
|
||||
|
||||
- Repo removed while popover is open: selection count and rows derive from live `repos`; stale ids must not count toward badge or selected count.
|
||||
- Repo added while open: `All` should include the new repo immediately (derive from current `repos` each render).
|
||||
- External repo mutations (sync/import) during search: filtered list updates from live `repos`; empty-state must not hide `Add project`.
|
||||
- SSH repos: show SSH indicator to avoid ambiguity with local repos of same display name.
|
||||
- 0 or 1 repo: hide repo filter section. Keep `Add project` visible outside the repo-count gate so users can still recover from low-repo states.
|
||||
- Multi-window: filter state is window-local; this redesign does not introduce cross-window synchronization.
|
||||
|
||||
## Accessibility / Focus
|
||||
|
||||
- Do not use `role="menuitemcheckbox"` unless the container is a true menu. Use semantic buttons or cmdk items with explicit `aria-selected`/checked indicators.
|
||||
- Autofocus search on open.
|
||||
- Esc closes via Radix Popover default.
|
||||
- Ensure focus returns to trigger on close.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Replace sidebar filter content with Popover + Command-based repo list.
|
||||
2. Keep `searchRepos()` as the only ranking source.
|
||||
3. Keep current filter setters and badge derivation semantics.
|
||||
4. Ensure scroll behavior works inside dialog parents by using `CommandList` (or by moving wheel handling to the actual scroll container).
|
||||
5. Validate with `pnpm typecheck` and `pnpm lint`.
|
||||
|
||||
## Test Scope
|
||||
|
||||
- Unit coverage remains in `visible-worktrees` for filter semantics.
|
||||
- Add/adjust component tests for:
|
||||
- keyboard navigation and selection,
|
||||
- stale repo id handling,
|
||||
- `All`/`None` behavior with live repo mutations,
|
||||
- `Add project` visibility at repo count 0/1.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.3.48-rc.1",
|
||||
"version": "1.3.48",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { parseAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
|
||||
const PANE = 'tab-1:0'
|
||||
|
||||
@@ -78,6 +79,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
receivedAt: expect.any(Number),
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({
|
||||
@@ -156,6 +158,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo::/tmp/worktree with "quotes"',
|
||||
connectionId: null,
|
||||
receivedAt: expect.any(Number),
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({
|
||||
@@ -1598,3 +1601,149 @@ describe('Last-status persistence', () => {
|
||||
expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentHookServer ingestRemote', () => {
|
||||
it('stamps connectionId and forwards a valid relay envelope to the listener', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1')
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'conn-1',
|
||||
receivedAt: expect.any(Number),
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
// Why: bypass parseAgentStatusPayload (which itself rejects bad states) by
|
||||
// constructing an obviously-invalid payload — `ingestRemote` is the trust
|
||||
// boundary we're testing, not the parser.
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'nonsense', prompt: '', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
// 201 chars — one past the listener's 200-char cap.
|
||||
const oversized = 'a'.repeat(201)
|
||||
server.ingestRemote(
|
||||
{ paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload },
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty connectionId', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, '')
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects whitespace-only connectionId', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote({ paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, ' ')
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects non-string tabId', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{ paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload },
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty paneKey after trim', () => {
|
||||
const server = new AgentHookServer()
|
||||
const payload = parseAgentStatusPayload(
|
||||
JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' })
|
||||
)
|
||||
if (!payload) {
|
||||
throw new Error('parseAgentStatusPayload returned null for a known-good fixture')
|
||||
}
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote({ paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, 'conn-1')
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => {
|
||||
// Why: the relay normally normalizes the payload on the wire, but a buggy
|
||||
// or malicious relay could forward an over-cap field. ingestRemote must
|
||||
// re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH
|
||||
// cap (200 chars) is enforced at the trust boundary.
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
const event = listener.mock.calls[0][0] as { payload: { prompt: string } }
|
||||
expect(event.payload.prompt.length).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
+239
-1431
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- test suite covers Claude runtime auth refresh, identity guards, and snapshot restore cases */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
chmodSync,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -77,17 +79,22 @@ function createStore(settings: GlobalSettings) {
|
||||
function createManagedClaudeAuth(
|
||||
rootDir: string,
|
||||
accountId: string,
|
||||
credentialsJson: string
|
||||
credentialsJson: string,
|
||||
oauthAccountJson = `{"accountUuid":"${accountId}"}\n`
|
||||
): string {
|
||||
const managedAuthPath = join(rootDir, 'claude-accounts', accountId, 'auth')
|
||||
mkdirSync(managedAuthPath, { recursive: true })
|
||||
writeFileSync(join(managedAuthPath, '.credentials.json'), credentialsJson, 'utf-8')
|
||||
writeFileSync(join(managedAuthPath, 'oauth-account.json'), `{"accountUuid":"${accountId}"}\n`)
|
||||
writeFileSync(join(managedAuthPath, 'oauth-account.json'), oauthAccountJson, 'utf-8')
|
||||
testState.managedKeychainCredentials.set(accountId, credentialsJson)
|
||||
return managedAuthPath
|
||||
}
|
||||
|
||||
function createClaudeAccount(id: string, managedAuthPath: string): ClaudeManagedAccount {
|
||||
function createClaudeAccount(
|
||||
id: string,
|
||||
managedAuthPath: string,
|
||||
overrides: Partial<ClaudeManagedAccount> = {}
|
||||
): ClaudeManagedAccount {
|
||||
return {
|
||||
id,
|
||||
email: 'user@example.com',
|
||||
@@ -97,10 +104,49 @@ function createClaudeAccount(id: string, managedAuthPath: string): ClaudeManaged
|
||||
organizationName: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
lastAuthenticatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createClaudeCredentialsJson(
|
||||
email: string,
|
||||
accessToken: string,
|
||||
organizationUuid: string | null = null,
|
||||
expiresAt = Date.now() + 60_000
|
||||
): string {
|
||||
return `${JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
email,
|
||||
...(organizationUuid ? { organizationUuid } : {}),
|
||||
accessToken,
|
||||
refreshToken: `${accessToken}-refresh`,
|
||||
expiresAt
|
||||
}
|
||||
})}\n`
|
||||
}
|
||||
|
||||
function createClaudeCredentialsWithoutEmail(
|
||||
accessToken: string,
|
||||
organizationUuid: string | null = null
|
||||
): string {
|
||||
return `${JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
...(organizationUuid ? { organizationUuid } : {}),
|
||||
accessToken,
|
||||
refreshToken: `${accessToken}-refresh`,
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})}\n`
|
||||
}
|
||||
|
||||
function readManagedCredentialsForTest(accountId: string, managedAuthPath: string): string | null {
|
||||
if (process.platform === 'darwin') {
|
||||
return testState.managedKeychainCredentials.get(accountId) ?? null
|
||||
}
|
||||
return readFileSync(join(managedAuthPath, '.credentials.json'), 'utf-8')
|
||||
}
|
||||
|
||||
describe('ClaudeRuntimeAuthService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -142,13 +188,9 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"managed"}\n')
|
||||
})
|
||||
|
||||
it('falls back to atomic write when the unchanged check cannot read the target', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
it('removes runtime credentials when deselecting with a missing system-default snapshot', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
writeFileSync(runtimeCredentialsPath, '{"token":"managed"}\n', 'utf-8')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
@@ -162,6 +204,36 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(existsSync(runtimeCredentialsPath)).toBe(false)
|
||||
if (process.platform === 'darwin') {
|
||||
expect(testState.activeKeychainCredentials).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to atomic write when the unchanged check cannot read the target', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
'{"token":"managed"}\n'
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
testState.managedKeychainCredentials.set('account-1', '{"token":"rotated"}\n')
|
||||
@@ -178,4 +250,798 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe('{"token":"rotated"}\n')
|
||||
})
|
||||
|
||||
it('tightens credential file permissions when unchanged content is already present', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
'{"token":"managed"}\n'
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
chmodSync(runtimeCredentialsPath, 0o644)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(statSync(runtimeCredentialsPath).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('reads back refreshed credentials when the Claude identity still matches', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original')
|
||||
const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
originalCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
|
||||
})
|
||||
|
||||
it('reads back verified same-account credentials on first sync after restart', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const originalCredentials = createClaudeCredentialsJson(
|
||||
'user@example.com',
|
||||
'original',
|
||||
null,
|
||||
1_000
|
||||
)
|
||||
const refreshedCredentials = createClaudeCredentialsJson(
|
||||
'user@example.com',
|
||||
'refreshed',
|
||||
null,
|
||||
2_000
|
||||
)
|
||||
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
originalCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
|
||||
})
|
||||
|
||||
it('rejects older same-account Claude credentials on first sync after restart', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const staleRuntimeCredentials = createClaudeCredentialsJson(
|
||||
'user@example.com',
|
||||
'stale',
|
||||
null,
|
||||
1_000
|
||||
)
|
||||
const managedCredentials = createClaudeCredentialsJson(
|
||||
'user@example.com',
|
||||
'managed-newer',
|
||||
null,
|
||||
2_000
|
||||
)
|
||||
writeFileSync(runtimeCredentialsPath, staleRuntimeCredentials, 'utf-8')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
managedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(managedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials)
|
||||
})
|
||||
|
||||
it('rejects runtime read-back from a different Claude identity', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected')
|
||||
const staleCredentials = createClaudeCredentialsJson('other@example.com', 'stale')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
selectedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
|
||||
})
|
||||
|
||||
it('rejects runtime read-back from the same Claude email in a different organization', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b')
|
||||
const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
selectedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
|
||||
})
|
||||
|
||||
it('rejects same-email Claude read-back using stored managed organization identity', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b')
|
||||
const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
selectedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
|
||||
})
|
||||
|
||||
it('rejects same-email Claude read-back using stored oauth-account organization identity', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected')
|
||||
const staleCredentials = createClaudeCredentialsJson('user@example.com', 'stale', 'org-a')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
selectedCredentials,
|
||||
'{"organizationUuid":"org-b"}\n'
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
|
||||
})
|
||||
|
||||
it('rejects no-email Claude read-back when organization identity conflicts', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const selectedCredentials = createClaudeCredentialsJson('user@example.com', 'selected', 'org-b')
|
||||
const staleCredentials = createClaudeCredentialsWithoutEmail('stale', 'org-a')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
selectedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath, { organizationUuid: 'org-b' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(selectedCredentials)
|
||||
})
|
||||
|
||||
it('rejects unverifiable refreshed runtime credentials', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const originalCredentials = createClaudeCredentialsWithoutEmail('original')
|
||||
const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
originalCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(originalCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(originalCredentials)
|
||||
})
|
||||
|
||||
it('restores the system default after rejecting unverifiable managed credentials', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
|
||||
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
|
||||
const originalCredentials = createClaudeCredentialsWithoutEmail('original')
|
||||
const refreshedCredentials = createClaudeCredentialsWithoutEmail('refreshed')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
originalCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
|
||||
})
|
||||
|
||||
it('restores system default after same-identity managed Claude refresh on deselect', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
|
||||
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed', 'org-1')
|
||||
const externalCredentials = createClaudeCredentialsJson('user@example.com', 'external', 'org-1')
|
||||
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
managedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath, {
|
||||
organizationUuid: 'org-1'
|
||||
})
|
||||
]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, externalCredentials, 'utf-8')
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(externalCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
|
||||
})
|
||||
|
||||
it('restores system default when stale Claude credentials are rejected on deselect', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
|
||||
const selectedCredentials = createClaudeCredentialsJson('selected@example.com', 'selected')
|
||||
const staleCredentials = createClaudeCredentialsJson('stale@example.com', 'stale')
|
||||
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
selectedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath, { email: 'selected@example.com' })
|
||||
]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, staleCredentials, 'utf-8')
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(selectedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
|
||||
})
|
||||
|
||||
it('does not persist unverifiable stale Claude credentials into another active account', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one')
|
||||
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
|
||||
const staleUnverifiableCredentials = createClaudeCredentialsWithoutEmail('stale')
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
account1Credentials
|
||||
)
|
||||
const managedAuthPath2 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-2',
|
||||
account2Credentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
|
||||
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
settings.activeClaudeManagedAccountId = 'account-2'
|
||||
await service.syncForCurrentSelection()
|
||||
writeFileSync(runtimeCredentialsPath, staleUnverifiableCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
|
||||
})
|
||||
|
||||
it('does not carry the reauth read-back skip across Claude account switches', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one')
|
||||
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
|
||||
const account2RefreshedCredentials = createClaudeCredentialsJson(
|
||||
'two@example.com',
|
||||
'two-refreshed'
|
||||
)
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
account1Credentials
|
||||
)
|
||||
const managedAuthPath2 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-2',
|
||||
account2Credentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
|
||||
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
service.clearLastWrittenCredentialsJson()
|
||||
settings.activeClaudeManagedAccountId = 'account-2'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, account2RefreshedCredentials, 'utf-8')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(
|
||||
account2RefreshedCredentials
|
||||
)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2RefreshedCredentials)
|
||||
})
|
||||
|
||||
it('does not apply inactive-account Claude reauth skip to the active account', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const account1Credentials = createClaudeCredentialsJson('one@example.com', 'one')
|
||||
const account1RefreshedCredentials = createClaudeCredentialsJson(
|
||||
'one@example.com',
|
||||
'one-refreshed'
|
||||
)
|
||||
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
account1Credentials
|
||||
)
|
||||
const managedAuthPath2 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-2',
|
||||
account2Credentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
|
||||
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, account1RefreshedCredentials, 'utf-8')
|
||||
service.clearLastWrittenCredentialsJson('account-2')
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(
|
||||
account1RefreshedCredentials
|
||||
)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1RefreshedCredentials)
|
||||
})
|
||||
|
||||
it('keeps external Claude logout when deselecting managed account', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
|
||||
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed')
|
||||
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
managedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
rmSync(runtimeCredentialsPath, { force: true })
|
||||
testState.activeKeychainCredentials = null
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(existsSync(runtimeCredentialsPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads back refreshed active keychain credentials on macOS', async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original')
|
||||
const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
originalCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
testState.activeKeychainCredentials = refreshedCredentials
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials)
|
||||
})
|
||||
|
||||
it('restores system default when mismatched Claude keychain auth appears before deselect', async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const systemCredentials = createClaudeCredentialsJson('system@example.com', 'system')
|
||||
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed')
|
||||
const externalKeychainCredentials = createClaudeCredentialsJson(
|
||||
'external@example.com',
|
||||
'external'
|
||||
)
|
||||
writeFileSync(runtimeCredentialsPath, systemCredentials, 'utf-8')
|
||||
testState.activeKeychainCredentials = systemCredentials
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
managedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)]
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
testState.activeKeychainCredentials = externalKeychainCredentials
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials)
|
||||
expect(testState.activeKeychainCredentials).toBe(systemCredentials)
|
||||
})
|
||||
|
||||
it('captures a fresh system-default snapshot when re-entering managed mode', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const systemCredentials1 = createClaudeCredentialsJson('system1@example.com', 'system1')
|
||||
const systemCredentials2 = createClaudeCredentialsJson('system2@example.com', 'system2')
|
||||
const managedCredentials = createClaudeCredentialsJson('user@example.com', 'managed')
|
||||
writeFileSync(runtimeCredentialsPath, systemCredentials1, 'utf-8')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
managedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
writeFileSync(runtimeCredentialsPath, systemCredentials2, 'utf-8')
|
||||
|
||||
settings.activeClaudeManagedAccountId = 'account-1'
|
||||
await service.syncForCurrentSelection()
|
||||
settings.activeClaudeManagedAccountId = null
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(systemCredentials2)
|
||||
})
|
||||
|
||||
it('reads back refreshed credentials for the outgoing Claude account before switching', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original')
|
||||
const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed')
|
||||
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
account1Original
|
||||
)
|
||||
const managedAuthPath2 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-2',
|
||||
account2Credentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
|
||||
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8')
|
||||
settings.activeClaudeManagedAccountId = 'account-2'
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
|
||||
})
|
||||
|
||||
it('routes refreshed Claude credentials to the matching managed account', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original')
|
||||
const account1Refreshed = createClaudeCredentialsJson('one@example.com', 'one-refreshed')
|
||||
const account2Credentials = createClaudeCredentialsJson('two@example.com', 'two')
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
account1Original
|
||||
)
|
||||
const managedAuthPath2 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-2',
|
||||
account2Credentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' }),
|
||||
createClaudeAccount('account-2', managedAuthPath2, { email: 'two@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-2'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
// A stale account-1 Claude process refreshed the shared runtime file after
|
||||
// Orca selected account-2. Persist that refresh to account-1, then restore
|
||||
// the selected account in the shared Claude runtime credentials.
|
||||
writeFileSync(runtimeCredentialsPath, account1Refreshed, 'utf-8')
|
||||
testState.activeKeychainCredentials = account1Refreshed
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed)
|
||||
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(account2Credentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
|
||||
if (process.platform === 'darwin') {
|
||||
expect(testState.activeKeychainCredentials).toBe(account2Credentials)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects ambiguous Claude read-back instead of choosing a managed account', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const originalCredentials = createClaudeCredentialsJson('same@example.com', 'same-original')
|
||||
const refreshedCredentials = createClaudeCredentialsJson('same@example.com', 'same-refreshed')
|
||||
const activeCredentials = createClaudeCredentialsJson('active@example.com', 'active')
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
originalCredentials
|
||||
)
|
||||
const managedAuthPath2 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-2',
|
||||
originalCredentials
|
||||
)
|
||||
const managedAuthPath3 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-3',
|
||||
activeCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'same@example.com' }),
|
||||
createClaudeAccount('account-2', managedAuthPath2, { email: 'same@example.com' }),
|
||||
createClaudeAccount('account-3', managedAuthPath3, { email: 'active@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-3'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8')
|
||||
testState.activeKeychainCredentials = refreshedCredentials
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(originalCredentials)
|
||||
expect(readManagedCredentialsForTest('account-2', managedAuthPath2)).toBe(originalCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(activeCredentials)
|
||||
if (process.platform === 'darwin') {
|
||||
expect(testState.activeKeychainCredentials).toBe(activeCredentials)
|
||||
}
|
||||
})
|
||||
|
||||
it('clears an invalid active Claude account before launch preparation', async () => {
|
||||
const settings = createSettings({
|
||||
activeClaudeManagedAccountId: 'missing-account'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
const preparation = await service.prepareForClaudeLaunch()
|
||||
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ activeClaudeManagedAccountId: null })
|
||||
expect(preparation.stripAuthEnv).toBe(false)
|
||||
expect(preparation.provenance).toBe('system')
|
||||
})
|
||||
|
||||
it('does not clobber fresh Claude credentials after clearLastWrittenCredentialsJson', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original')
|
||||
const reauthedCredentials = createClaudeCredentialsJson('user@example.com', 'reauthed')
|
||||
const managedAuthPath = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
originalCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [createClaudeAccount('account-1', managedAuthPath)],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
testState.managedKeychainCredentials.set('account-1', reauthedCredentials)
|
||||
writeFileSync(join(managedAuthPath, '.credentials.json'), reauthedCredentials, 'utf-8')
|
||||
service.clearLastWrittenCredentialsJson()
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: Claude account switching has one safety
|
||||
boundary: runtime auth materialization. Keeping file, Keychain, snapshot, and
|
||||
env-patch semantics together prevents PTY launch and quota fetch paths drifting. */
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import type { ClaudeManagedAccount } from '../../shared/types'
|
||||
@@ -30,6 +30,16 @@ type ClaudeSystemDefaultSnapshot = {
|
||||
capturedAt: number
|
||||
}
|
||||
|
||||
type ClaudeAuthIdentity = {
|
||||
email: string | null
|
||||
organizationUuid: string | null
|
||||
}
|
||||
|
||||
type ClaudeReadBackResult = { status: 'unchanged' | 'persisted' | 'rejected' }
|
||||
type ClaudeReadBackMatch =
|
||||
| { kind: 'matched'; account: ClaudeManagedAccount }
|
||||
| { kind: 'none' | 'ambiguous' }
|
||||
|
||||
export class ClaudeRuntimeAuthService {
|
||||
private readonly pathResolver = new ClaudeRuntimePathResolver()
|
||||
private mutationQueue: Promise<unknown> = Promise.resolve()
|
||||
@@ -39,6 +49,7 @@ export class ClaudeRuntimeAuthService {
|
||||
// an external login (e.g. `claude auth login`) overwrote it — so Orca adopts
|
||||
// the file as the new system default instead of restoring a stale snapshot.
|
||||
private lastWrittenCredentialsJson: string | null = null
|
||||
private skipNextReadBackForAccountId: string | null = null
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.initializeLastSyncedState()
|
||||
@@ -63,7 +74,7 @@ export class ClaudeRuntimeAuthService {
|
||||
await this.serializeMutation(async () => {
|
||||
const settings = this.store.getSettings()
|
||||
if (!settings.activeClaudeManagedAccountId) {
|
||||
await this.restoreSystemDefaultSnapshot()
|
||||
await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
|
||||
this.lastSyncedAccountId = null
|
||||
return
|
||||
}
|
||||
@@ -100,37 +111,66 @@ export class ClaudeRuntimeAuthService {
|
||||
settings.claudeManagedAccounts,
|
||||
settings.activeClaudeManagedAccountId
|
||||
)
|
||||
const previousAccount = this.getActiveAccount(
|
||||
settings.claudeManagedAccounts,
|
||||
this.lastSyncedAccountId
|
||||
)
|
||||
let outgoingReadBackResult: ClaudeReadBackResult = { status: 'unchanged' }
|
||||
if (previousAccount && previousAccount.id !== activeAccount?.id) {
|
||||
outgoingReadBackResult = await this.readBackRefreshedTokensForAccount(previousAccount, {
|
||||
updateLastWrittenCredentialsJson: true
|
||||
})
|
||||
}
|
||||
if (!activeAccount) {
|
||||
if (settings.activeClaudeManagedAccountId) {
|
||||
this.store.updateSettings({ activeClaudeManagedAccountId: null })
|
||||
}
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
await this.restoreSystemDefaultSnapshot()
|
||||
await this.restoreSystemDefaultSnapshot({
|
||||
detectExternalLogin: outgoingReadBackResult.status !== 'rejected'
|
||||
})
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await this.captureSystemDefaultSnapshotIfNeeded()
|
||||
|
||||
const credentialsJson = await this.readManagedCredentials(activeAccount)
|
||||
let credentialsJson = await this.readManagedCredentials(activeAccount)
|
||||
if (!credentialsJson) {
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Active managed account is missing credentials, restoring system default'
|
||||
)
|
||||
this.store.updateSettings({ activeClaudeManagedAccountId: null })
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
await this.restoreSystemDefaultSnapshot()
|
||||
await this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.lastSyncedAccountId === null) {
|
||||
await this.captureSystemDefaultSnapshot({ force: true })
|
||||
}
|
||||
|
||||
// Why: Claude CLI refreshes expired OAuth tokens and writes them back to
|
||||
// .credentials.json. If we detect the runtime file differs from what Orca
|
||||
// last wrote, the CLI must have refreshed — so we preserve those tokens
|
||||
// back to managed storage before overwriting runtime with managed state.
|
||||
if (this.lastSyncedAccountId === activeAccount.id) {
|
||||
await this.readBackRefreshedTokens(activeAccount)
|
||||
if (this.skipNextReadBackForAccountId === activeAccount.id) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
} else {
|
||||
const readBackResult = await this.readBackRefreshedTokens(credentialsJson, {
|
||||
updateLastWrittenCredentialsJson: true
|
||||
})
|
||||
if (readBackResult.status === 'persisted') {
|
||||
credentialsJson = (await this.readManagedCredentials(activeAccount)) ?? credentialsJson
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastSyncedAccountId !== activeAccount.id) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
}
|
||||
this.writeRuntimeCredentials(credentialsJson)
|
||||
if (process.platform === 'darwin') {
|
||||
await writeActiveClaudeKeychainCredentials(credentialsJson)
|
||||
@@ -143,40 +183,105 @@ export class ClaudeRuntimeAuthService {
|
||||
// re-auth or add-account. Those flows write fresh tokens to managed storage,
|
||||
// so the read-back must be skipped to avoid overwriting them with stale
|
||||
// runtime tokens.
|
||||
clearLastWrittenCredentialsJson(): void {
|
||||
this.lastWrittenCredentialsJson = null
|
||||
clearLastWrittenCredentialsJson(
|
||||
accountId = this.store.getSettings().activeClaudeManagedAccountId
|
||||
): void {
|
||||
if (accountId === this.store.getSettings().activeClaudeManagedAccountId) {
|
||||
this.lastWrittenCredentialsJson = null
|
||||
}
|
||||
this.skipNextReadBackForAccountId = accountId
|
||||
}
|
||||
|
||||
private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise<void> {
|
||||
private async readBackRefreshedTokens(
|
||||
baselineCredentialsJson: string,
|
||||
options: { updateLastWrittenCredentialsJson: boolean }
|
||||
): Promise<ClaudeReadBackResult> {
|
||||
try {
|
||||
if (this.lastWrittenCredentialsJson === null) {
|
||||
return
|
||||
const runtimeContents = await this.readRuntimeCredentialsForReadBack(baselineCredentialsJson)
|
||||
if (!runtimeContents) {
|
||||
return { status: 'unchanged' }
|
||||
}
|
||||
if (
|
||||
this.lastWrittenCredentialsJson !== null &&
|
||||
runtimeContents === this.lastWrittenCredentialsJson
|
||||
) {
|
||||
return { status: 'unchanged' }
|
||||
}
|
||||
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
if (!existsSync(paths.credentialsPath)) {
|
||||
return
|
||||
const match = await this.findManagedAccountForRuntimeCredentials(runtimeContents)
|
||||
if (match.kind !== 'matched') {
|
||||
if (match.kind === 'ambiguous') {
|
||||
console.warn('[claude-runtime-auth] Refusing ambiguous Claude auth read-back')
|
||||
}
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
// Why: on cold app start we cannot tell whether matching runtime
|
||||
// credentials are a fresh CLI refresh or stale state unless token
|
||||
// metadata proves runtime is newer than managed storage.
|
||||
if (
|
||||
this.lastWrittenCredentialsJson === null &&
|
||||
!this.runtimeCredentialsAreFresher(runtimeContents, baselineCredentialsJson)
|
||||
) {
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
|
||||
const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8')
|
||||
if (runtimeContents === this.lastWrittenCredentialsJson) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
await writeManagedClaudeKeychainCredentials(account.id, runtimeContents)
|
||||
} else {
|
||||
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
|
||||
writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 })
|
||||
await this.writeManagedCredentials(match.account, runtimeContents)
|
||||
if (options.updateLastWrittenCredentialsJson) {
|
||||
this.writeRuntimeCredentials(runtimeContents)
|
||||
this.lastWrittenCredentialsJson = runtimeContents
|
||||
if (process.platform === 'darwin') {
|
||||
await writeActiveClaudeKeychainCredentials(runtimeContents)
|
||||
}
|
||||
}
|
||||
return { status: 'persisted' }
|
||||
} catch (error) {
|
||||
// Why: read-back is best-effort. A transient fs error must not block the
|
||||
// forward sync path — the worst case is one more stale-token cycle, which
|
||||
// is strictly better than failing the entire sync.
|
||||
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
}
|
||||
|
||||
private async readRuntimeCredentialsForReadBack(
|
||||
baselineCredentialsJson: string
|
||||
): Promise<string | null> {
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
const fileCredentials = existsSync(paths.credentialsPath)
|
||||
? readFileSync(paths.credentialsPath, 'utf-8')
|
||||
: null
|
||||
if (process.platform === 'darwin') {
|
||||
const keychainCredentials = await readActiveClaudeKeychainCredentials()
|
||||
if (this.lastWrittenCredentialsJson === null) {
|
||||
if (keychainCredentials && keychainCredentials !== baselineCredentialsJson) {
|
||||
return keychainCredentials
|
||||
}
|
||||
if (fileCredentials && fileCredentials !== baselineCredentialsJson) {
|
||||
return fileCredentials
|
||||
}
|
||||
return keychainCredentials ?? fileCredentials
|
||||
}
|
||||
if (keychainCredentials && keychainCredentials !== this.lastWrittenCredentialsJson) {
|
||||
return keychainCredentials
|
||||
}
|
||||
}
|
||||
if (!fileCredentials) {
|
||||
return null
|
||||
}
|
||||
return fileCredentials
|
||||
}
|
||||
|
||||
private async readBackRefreshedTokensForAccount(
|
||||
account: ClaudeManagedAccount,
|
||||
options: { updateLastWrittenCredentialsJson: boolean }
|
||||
): Promise<ClaudeReadBackResult> {
|
||||
const managedCredentialsJson = await this.readManagedCredentials(account)
|
||||
if (!managedCredentialsJson) {
|
||||
return { status: 'unchanged' }
|
||||
}
|
||||
return this.readBackRefreshedTokens(managedCredentialsJson, options)
|
||||
}
|
||||
|
||||
private getPreparation(): ClaudeRuntimeAuthPreparation {
|
||||
const settings = this.store.getSettings()
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
@@ -198,6 +303,161 @@ export class ClaudeRuntimeAuthService {
|
||||
return accounts.find((account) => account.id === activeAccountId) ?? null
|
||||
}
|
||||
|
||||
private async findManagedAccountForRuntimeCredentials(
|
||||
runtimeCredentialsJson: string
|
||||
): Promise<ClaudeReadBackMatch> {
|
||||
const matches: ClaudeManagedAccount[] = []
|
||||
for (const account of this.store.getSettings().claudeManagedAccounts) {
|
||||
const managedCredentialsJson = await this.readManagedCredentials(account)
|
||||
if (!managedCredentialsJson) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
this.runtimeCredentialsMatchAccount(
|
||||
runtimeCredentialsJson,
|
||||
account,
|
||||
managedCredentialsJson,
|
||||
this.readManagedOauthAccount(account)
|
||||
) === 'match'
|
||||
) {
|
||||
matches.push(account)
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
return { kind: 'matched', account: matches[0] }
|
||||
}
|
||||
return { kind: matches.length === 0 ? 'none' : 'ambiguous' }
|
||||
}
|
||||
|
||||
private runtimeCredentialsMatchAccount(
|
||||
runtimeCredentialsJson: string,
|
||||
account: ClaudeManagedAccount,
|
||||
managedCredentialsJson: string,
|
||||
managedOauthAccount: unknown
|
||||
): 'match' | 'mismatch' | 'unverifiable' {
|
||||
const identity = this.readIdentityFromCredentials(runtimeCredentialsJson)
|
||||
if (!identity) {
|
||||
return 'mismatch'
|
||||
}
|
||||
const managedIdentity = this.readIdentityFromCredentials(managedCredentialsJson)
|
||||
const managedOauthIdentity = this.readIdentityFromOauthAccount(managedOauthAccount)
|
||||
|
||||
// Why: this mirrors the Codex runtime-home guard. If another Claude login
|
||||
// or missed live process rewrites shared runtime credentials, do not
|
||||
// persist those credentials into the selected managed account.
|
||||
const selectedOrganizationUuid = this.normalizeField(
|
||||
account.organizationUuid ??
|
||||
managedIdentity?.organizationUuid ??
|
||||
managedOauthIdentity.organizationUuid
|
||||
)
|
||||
if (selectedOrganizationUuid && !identity.organizationUuid) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (
|
||||
selectedOrganizationUuid &&
|
||||
identity.organizationUuid &&
|
||||
selectedOrganizationUuid !== identity.organizationUuid
|
||||
) {
|
||||
return 'mismatch'
|
||||
}
|
||||
if (!identity.email) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (account.email && identity.email && this.normalizeField(account.email) !== identity.email) {
|
||||
return 'mismatch'
|
||||
}
|
||||
|
||||
return 'match'
|
||||
}
|
||||
|
||||
private readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const oauth = this.asRecord(parsed.claudeAiOauth)
|
||||
return {
|
||||
email: this.normalizeField(this.readString(oauth, 'email')),
|
||||
organizationUuid: this.normalizeField(
|
||||
this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private runtimeCredentialsAreFresher(
|
||||
runtimeCredentialsJson: string,
|
||||
managedCredentialsJson: string
|
||||
): boolean {
|
||||
const runtimeFreshness = this.readFreshnessFromCredentials(runtimeCredentialsJson)
|
||||
const managedFreshness = this.readFreshnessFromCredentials(managedCredentialsJson)
|
||||
return (
|
||||
runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness
|
||||
)
|
||||
}
|
||||
|
||||
private readFreshnessFromCredentials(credentialsJson: string): number | null {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(credentialsJson) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const oauth = this.asRecord(parsed.claudeAiOauth)
|
||||
return (
|
||||
this.readNumber(oauth, 'expiresAt') ??
|
||||
this.readNumber(oauth, 'expires_at') ??
|
||||
this.readNumber(oauth, 'expiry') ??
|
||||
this.readNumber(oauth, 'expires')
|
||||
)
|
||||
}
|
||||
|
||||
private readIdentityFromOauthAccount(oauthAccount: unknown): ClaudeAuthIdentity {
|
||||
const oauth = this.asRecord(oauthAccount)
|
||||
return {
|
||||
email: this.normalizeField(
|
||||
this.readString(oauth, 'emailAddress') ?? this.readString(oauth, 'email')
|
||||
),
|
||||
organizationUuid: this.normalizeField(
|
||||
this.readString(oauth, 'organizationUuid') ?? this.readString(oauth, 'organizationId')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
private readString(value: Record<string, unknown> | null, key: string): string | null {
|
||||
const candidate = value?.[key]
|
||||
return typeof candidate === 'string' ? candidate : null
|
||||
}
|
||||
|
||||
private readNumber(value: Record<string, unknown> | null, key: string): number | null {
|
||||
const candidate = value?.[key]
|
||||
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
if (typeof candidate === 'string') {
|
||||
const parsed = Number(candidate)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private normalizeField(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
|
||||
private async readManagedCredentials(account: ClaudeManagedAccount): Promise<string | null> {
|
||||
if (process.platform === 'darwin') {
|
||||
return readManagedClaudeKeychainCredentials(account.id)
|
||||
@@ -209,6 +469,18 @@ export class ClaudeRuntimeAuthService {
|
||||
return readFileSync(credentialsPath, 'utf-8')
|
||||
}
|
||||
|
||||
private async writeManagedCredentials(
|
||||
account: ClaudeManagedAccount,
|
||||
credentialsJson: string
|
||||
): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
await writeManagedClaudeKeychainCredentials(account.id, credentialsJson)
|
||||
return
|
||||
}
|
||||
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
|
||||
writeFileAtomically(credentialsPath, credentialsJson, { mode: 0o600 })
|
||||
}
|
||||
|
||||
private readManagedOauthAccount(account: ClaudeManagedAccount): unknown {
|
||||
const oauthPath = join(account.managedAuthPath, 'oauth-account.json')
|
||||
if (!existsSync(oauthPath)) {
|
||||
@@ -221,9 +493,9 @@ export class ClaudeRuntimeAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private async captureSystemDefaultSnapshotIfNeeded(): Promise<void> {
|
||||
private async captureSystemDefaultSnapshot(options: { force: boolean }): Promise<void> {
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
if (existsSync(snapshotPath)) {
|
||||
if (!options.force && existsSync(snapshotPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -241,13 +513,23 @@ export class ClaudeRuntimeAuthService {
|
||||
this.writeJson(snapshotPath, snapshot)
|
||||
}
|
||||
|
||||
private async restoreSystemDefaultSnapshot(): Promise<void> {
|
||||
if (this.detectExternalLoginAndUpdateSnapshot()) {
|
||||
private async restoreSystemDefaultSnapshot(options: {
|
||||
detectExternalLogin: boolean
|
||||
}): Promise<void> {
|
||||
const externalState = options.detectExternalLogin
|
||||
? await this.detectExternalLoginAndUpdateSnapshot()
|
||||
: 'none'
|
||||
if (externalState === 'file-logout') {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
if (!existsSync(snapshotPath)) {
|
||||
rmSync(this.pathResolver.getRuntimePaths().credentialsPath, { force: true })
|
||||
if (process.platform === 'darwin') {
|
||||
await deleteActiveClaudeKeychainCredentials()
|
||||
}
|
||||
this.lastWrittenCredentialsJson = null
|
||||
return
|
||||
}
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as ClaudeSystemDefaultSnapshot
|
||||
@@ -258,9 +540,11 @@ export class ClaudeRuntimeAuthService {
|
||||
}
|
||||
this.writeRuntimeOauthAccount(snapshot.configOauthAccount)
|
||||
if (process.platform === 'darwin') {
|
||||
await (snapshot.keychainCredentialsJson !== null
|
||||
? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson)
|
||||
: deleteActiveClaudeKeychainCredentials())
|
||||
if (externalState !== 'keychain-change') {
|
||||
await (snapshot.keychainCredentialsJson !== null
|
||||
? writeActiveClaudeKeychainCredentials(snapshot.keychainCredentialsJson)
|
||||
: deleteActiveClaudeKeychainCredentials())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,23 +552,43 @@ export class ClaudeRuntimeAuthService {
|
||||
// the credentials file while a managed account was active. If the file
|
||||
// differs from what Orca last wrote, that external login becomes the new
|
||||
// system default — no manual "refresh" button needed.
|
||||
private detectExternalLoginAndUpdateSnapshot(): boolean {
|
||||
private async detectExternalLoginAndUpdateSnapshot(): Promise<
|
||||
'none' | 'file-logout' | 'keychain-change'
|
||||
> {
|
||||
if (this.lastWrittenCredentialsJson === null) {
|
||||
return false
|
||||
return 'none'
|
||||
}
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
if (!existsSync(paths.credentialsPath)) {
|
||||
return false
|
||||
const currentKeychainCredentials =
|
||||
process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null
|
||||
if (
|
||||
process.platform === 'darwin' &&
|
||||
currentKeychainCredentials === this.lastWrittenCredentialsJson
|
||||
) {
|
||||
return 'none'
|
||||
}
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
rmSync(snapshotPath, { force: true })
|
||||
this.lastWrittenCredentialsJson = null
|
||||
return 'file-logout'
|
||||
}
|
||||
const currentCredentials = readFileSync(paths.credentialsPath, 'utf-8')
|
||||
const currentKeychainCredentials =
|
||||
process.platform === 'darwin' ? await readActiveClaudeKeychainCredentials() : null
|
||||
if (currentCredentials === this.lastWrittenCredentialsJson) {
|
||||
return false
|
||||
if (
|
||||
process.platform === 'darwin' &&
|
||||
currentKeychainCredentials !== this.lastWrittenCredentialsJson
|
||||
) {
|
||||
return 'keychain-change'
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
// External login detected — adopt current state as the new system default
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
rmSync(snapshotPath, { force: true })
|
||||
this.lastWrittenCredentialsJson = null
|
||||
return true
|
||||
return 'file-logout'
|
||||
}
|
||||
|
||||
private readRuntimeOauthAccount(): unknown {
|
||||
@@ -316,10 +620,17 @@ export class ClaudeRuntimeAuthService {
|
||||
mkdirSync(dirname(credentialsPath), { recursive: true })
|
||||
// Why: repeated Claude spawns sync auth, but credentials rarely change.
|
||||
// Skipping unchanged rewrites avoids Windows EPERM contention in #1507.
|
||||
if (this.lastWrittenCredentialsJson === contents && existsSync(credentialsPath)) {
|
||||
// Still verify the file because another Claude process may have rewritten
|
||||
// runtime credentials since Orca last materialized them.
|
||||
if (
|
||||
this.lastWrittenCredentialsJson === contents &&
|
||||
this.fileContentsEqual(credentialsPath, contents)
|
||||
) {
|
||||
this.ensureOwnerOnlyMode(credentialsPath)
|
||||
return
|
||||
}
|
||||
if (this.fileContentsEqual(credentialsPath, contents)) {
|
||||
this.ensureOwnerOnlyMode(credentialsPath)
|
||||
this.lastWrittenCredentialsJson = contents
|
||||
return
|
||||
}
|
||||
@@ -345,6 +656,17 @@ export class ClaudeRuntimeAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private ensureOwnerOnlyMode(targetPath: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
chmodSync(targetPath, 0o600)
|
||||
} catch {
|
||||
/* Best effort: the next atomic write will set the restrictive mode. */
|
||||
}
|
||||
}
|
||||
|
||||
private readJsonObject(targetPath: string): Record<string, unknown> {
|
||||
if (!existsSync(targetPath)) {
|
||||
return {}
|
||||
|
||||
@@ -115,7 +115,7 @@ export class ClaudeAccountService {
|
||||
claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account],
|
||||
activeClaudeManagedAccountId: account.id
|
||||
})
|
||||
this.runtimeAuth.clearLastWrittenCredentialsJson()
|
||||
this.runtimeAuth.clearLastWrittenCredentialsJson(accountId)
|
||||
await this.syncRuntimeAuthWithLivePtyGate()
|
||||
await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId)
|
||||
return this.getSnapshot()
|
||||
@@ -154,7 +154,7 @@ export class ClaudeAccountService {
|
||||
)
|
||||
})
|
||||
try {
|
||||
this.runtimeAuth.clearLastWrittenCredentialsJson()
|
||||
this.runtimeAuth.clearLastWrittenCredentialsJson(accountId)
|
||||
await this.syncRuntimeAuthWithLivePtyGate()
|
||||
await this.rateLimits.refreshForClaudeAccountChange()
|
||||
return this.getSnapshot()
|
||||
|
||||
@@ -100,12 +100,21 @@ function normalizeModelForPricing(model: string | null): string | null {
|
||||
if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) {
|
||||
return 'claude-sonnet-3-7'
|
||||
}
|
||||
// Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still
|
||||
// present in historical Claude Code/SDK logs read off disk. Match them so
|
||||
// their cost is not silently dropped from the breakdown.
|
||||
if (lower.includes('3-5-sonnet') || lower.includes('3.5-sonnet')) {
|
||||
return 'claude-sonnet-3-7'
|
||||
}
|
||||
if (lower.includes('haiku-4-5')) {
|
||||
return 'claude-haiku-4-5'
|
||||
}
|
||||
if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) {
|
||||
return 'claude-haiku-3-5'
|
||||
}
|
||||
if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) {
|
||||
return 'claude-haiku-3-5'
|
||||
}
|
||||
if (lower.includes('haiku-3')) {
|
||||
return 'claude-haiku-3'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ semantics from drifting across PTY launch, login, and quota fetch paths. */
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
@@ -18,6 +19,26 @@ import type { CodexManagedAccount } from '../../shared/types'
|
||||
import type { Store } from '../persistence'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
|
||||
type CodexAuthIdentity = {
|
||||
email: string | null
|
||||
providerAccountId: string | null
|
||||
workspaceAccountId: string | null
|
||||
}
|
||||
|
||||
type CodexSystemDefaultSnapshot = {
|
||||
authJson: string | null
|
||||
}
|
||||
|
||||
type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected'
|
||||
type CodexReadBackMatch =
|
||||
| {
|
||||
kind: 'matched'
|
||||
account: CodexManagedAccount
|
||||
managedAuthPath: string
|
||||
managedAuthContents: string
|
||||
}
|
||||
| { kind: 'none' | 'ambiguous' }
|
||||
|
||||
export class CodexRuntimeHomeService {
|
||||
// Why: tracks whether auth.json is currently managed by Orca. When null,
|
||||
// Orca does NOT own auth.json and must not overwrite external changes
|
||||
@@ -31,6 +52,7 @@ export class CodexRuntimeHomeService {
|
||||
// login (e.g. `codex auth login`) overwrote it — so Orca adopts the file as
|
||||
// the new system default instead of restoring a stale snapshot.
|
||||
private lastWrittenAuthJson: string | null = null
|
||||
private skipNextReadBackForAccountId: string | null = null
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.safeMigrateLegacyManagedState()
|
||||
@@ -44,31 +66,47 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
|
||||
prepareForCodexLaunch(): string {
|
||||
this.safeSyncForCurrentSelection()
|
||||
this.syncForCurrentSelection()
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
prepareForRateLimitFetch(): string {
|
||||
this.safeSyncForCurrentSelection()
|
||||
this.syncForCurrentSelection()
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
syncForCurrentSelection(): void {
|
||||
this.captureSystemDefaultSnapshotIfNeeded()
|
||||
|
||||
const settings = this.store.getSettings()
|
||||
if (this.lastSyncedAccountId === null) {
|
||||
this.captureSystemDefaultSnapshot({ force: false })
|
||||
}
|
||||
const activeAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
settings.activeCodexManagedAccountId
|
||||
)
|
||||
const previousAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
this.lastSyncedAccountId
|
||||
)
|
||||
let outgoingReadBackResult: CodexReadBackResult = 'unchanged'
|
||||
if (previousAccount && previousAccount.id !== activeAccount?.id) {
|
||||
outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, {
|
||||
updateLastWrittenAuthJson: true
|
||||
})
|
||||
}
|
||||
if (!activeAccount) {
|
||||
if (settings.activeCodexManagedAccountId) {
|
||||
this.store.updateSettings({ activeCodexManagedAccountId: null })
|
||||
}
|
||||
// Why: only restore the snapshot when transitioning FROM a managed
|
||||
// account back to system default. When no managed account was ever
|
||||
// active, auth.json belongs to the user and Orca must not touch it.
|
||||
// This prevents overwriting external auth changes (codex login or other
|
||||
// tools) on every PTY launch / rate-limit fetch.
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
this.restoreSystemDefaultSnapshot()
|
||||
this.restoreSystemDefaultSnapshot({
|
||||
detectExternalLogin: outgoingReadBackResult !== 'rejected'
|
||||
})
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
@@ -81,20 +119,33 @@ export class CodexRuntimeHomeService {
|
||||
)
|
||||
this.store.updateSettings({ activeCodexManagedAccountId: null })
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
this.restoreSystemDefaultSnapshot()
|
||||
this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.lastSyncedAccountId === null) {
|
||||
this.captureSystemDefaultSnapshot({ force: true })
|
||||
}
|
||||
|
||||
// Why: Codex CLI refreshes expired OAuth tokens and writes them back to
|
||||
// ~/.codex/auth.json. If we detect the runtime file differs from what Orca
|
||||
// last wrote, the CLI must have refreshed — so we preserve those tokens
|
||||
// back to managed storage before overwriting runtime with managed state.
|
||||
if (this.lastSyncedAccountId === activeAccount.id) {
|
||||
this.readBackRefreshedTokens(activeAuthPath)
|
||||
if (this.skipNextReadBackForAccountId === activeAccount.id) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
} else {
|
||||
this.readBackRefreshedTokens({
|
||||
updateLastWrittenAuthJson: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastSyncedAccountId !== activeAccount.id) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
}
|
||||
this.lastSyncedAccountId = activeAccount.id
|
||||
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
|
||||
}
|
||||
@@ -103,35 +154,64 @@ export class CodexRuntimeHomeService {
|
||||
// re-auth or add-account. Those flows write fresh tokens to managed storage,
|
||||
// so the read-back must be skipped to avoid overwriting them with stale
|
||||
// runtime tokens.
|
||||
clearLastWrittenAuthJson(): void {
|
||||
this.lastWrittenAuthJson = null
|
||||
clearLastWrittenAuthJson(accountId = this.store.getSettings().activeCodexManagedAccountId): void {
|
||||
if (accountId === this.store.getSettings().activeCodexManagedAccountId) {
|
||||
this.lastWrittenAuthJson = null
|
||||
}
|
||||
this.skipNextReadBackForAccountId = accountId
|
||||
}
|
||||
|
||||
private readBackRefreshedTokens(managedAuthPath: string): void {
|
||||
private readBackRefreshedTokens(options: {
|
||||
updateLastWrittenAuthJson: boolean
|
||||
}): CodexReadBackResult {
|
||||
try {
|
||||
const runtimeAuthPath = this.getRuntimeAuthPath()
|
||||
if (!existsSync(runtimeAuthPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.lastWrittenAuthJson === null) {
|
||||
return
|
||||
return 'unchanged'
|
||||
}
|
||||
|
||||
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
|
||||
if (runtimeContents === this.lastWrittenAuthJson) {
|
||||
return
|
||||
if (this.lastWrittenAuthJson !== null && runtimeContents === this.lastWrittenAuthJson) {
|
||||
return 'unchanged'
|
||||
}
|
||||
|
||||
writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 })
|
||||
const match = this.findManagedAccountForRuntimeAuth(runtimeContents)
|
||||
if (match.kind !== 'matched') {
|
||||
if (match.kind === 'ambiguous') {
|
||||
console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back')
|
||||
}
|
||||
return 'rejected'
|
||||
}
|
||||
// Why: after app restart, Orca has no last-written baseline. Identity
|
||||
// alone cannot prove runtime auth is newer than managed storage.
|
||||
if (
|
||||
this.lastWrittenAuthJson === null &&
|
||||
!this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents)
|
||||
) {
|
||||
return 'rejected'
|
||||
}
|
||||
|
||||
writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 })
|
||||
if (options.updateLastWrittenAuthJson) {
|
||||
this.lastWrittenAuthJson = runtimeContents
|
||||
}
|
||||
return 'persisted'
|
||||
} catch (error) {
|
||||
// Why: read-back is best-effort. A transient fs error must not block the
|
||||
// forward sync path — the worst case is one more stale-token cycle, which
|
||||
// is strictly better than failing the entire sync.
|
||||
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
|
||||
private readBackRefreshedTokensForAccount(
|
||||
_account: CodexManagedAccount,
|
||||
options: { updateLastWrittenAuthJson: boolean }
|
||||
): CodexReadBackResult {
|
||||
return this.readBackRefreshedTokens(options)
|
||||
}
|
||||
|
||||
private safeSyncForCurrentSelection(): void {
|
||||
try {
|
||||
this.syncForCurrentSelection()
|
||||
@@ -150,6 +230,208 @@ export class CodexRuntimeHomeService {
|
||||
return accounts.find((account) => account.id === activeAccountId) ?? null
|
||||
}
|
||||
|
||||
private findManagedAccountForRuntimeAuth(runtimeAuthContents: string): CodexReadBackMatch {
|
||||
const matches: {
|
||||
account: CodexManagedAccount
|
||||
managedAuthPath: string
|
||||
managedAuthContents: string
|
||||
}[] = []
|
||||
for (const account of this.store.getSettings().codexManagedAccounts) {
|
||||
const managedAuthPath = join(account.managedHomePath, 'auth.json')
|
||||
if (!existsSync(managedAuthPath)) {
|
||||
continue
|
||||
}
|
||||
const managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
|
||||
if (this.runtimeAuthMatchesAccount(runtimeAuthContents, account, managedAuthContents)) {
|
||||
matches.push({ account, managedAuthPath, managedAuthContents })
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1) {
|
||||
return { kind: 'matched', ...matches[0] }
|
||||
}
|
||||
return { kind: matches.length === 0 ? 'none' : 'ambiguous' }
|
||||
}
|
||||
|
||||
private runtimeAuthMatchesAccount(
|
||||
runtimeAuthContents: string,
|
||||
activeAccount: CodexManagedAccount,
|
||||
managedAuthContents: string
|
||||
): boolean {
|
||||
const identity = this.readIdentityFromAuthContents(runtimeAuthContents)
|
||||
if (!identity) {
|
||||
return false
|
||||
}
|
||||
const managedIdentity = this.readIdentityFromAuthContents(managedAuthContents)
|
||||
|
||||
// Why: old live Codex PTYs can still write refreshed tokens into the
|
||||
// shared runtime home after the user switches accounts. Never persist
|
||||
// that write into the newly active managed account unless the auth claims
|
||||
// still match the account Orca believes is selected.
|
||||
const selectedEmail = this.firstNonNull(
|
||||
this.normalizeField(activeAccount.email),
|
||||
managedIdentity?.email
|
||||
)
|
||||
const selectedProviderId = this.firstNonNull(
|
||||
this.normalizeField(activeAccount.providerAccountId),
|
||||
managedIdentity?.providerAccountId
|
||||
)
|
||||
const selectedWorkspaceId = this.firstNonNull(
|
||||
this.normalizeField(activeAccount.workspaceAccountId),
|
||||
managedIdentity?.workspaceAccountId
|
||||
)
|
||||
const emailMatches = Boolean(
|
||||
selectedEmail && identity.email && selectedEmail === identity.email
|
||||
)
|
||||
if (selectedEmail && identity.email && selectedEmail !== identity.email) {
|
||||
return false
|
||||
}
|
||||
if (!this.identityFieldMatches(selectedProviderId, identity.providerAccountId)) {
|
||||
return false
|
||||
}
|
||||
if (!this.identityFieldMatches(selectedWorkspaceId, identity.workspaceAccountId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasStrongIdentity = Boolean(
|
||||
(selectedProviderId && identity.providerAccountId) ||
|
||||
(selectedWorkspaceId && identity.workspaceAccountId)
|
||||
)
|
||||
return (
|
||||
hasStrongIdentity ||
|
||||
(emailMatches && !identity.providerAccountId && !identity.workspaceAccountId)
|
||||
)
|
||||
}
|
||||
|
||||
private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean {
|
||||
const runtimeFreshness = this.readFreshnessFromAuthContents(runtimeAuthContents)
|
||||
const managedFreshness = this.readFreshnessFromAuthContents(managedAuthContents)
|
||||
return (
|
||||
runtimeFreshness !== null && managedFreshness !== null && runtimeFreshness > managedFreshness
|
||||
)
|
||||
}
|
||||
|
||||
private identityFieldMatches(selectedField: string | null, runtimeField: string | null): boolean {
|
||||
return !selectedField || Boolean(runtimeField && selectedField === runtimeField)
|
||||
}
|
||||
|
||||
private firstNonNull(...values: (string | null | undefined)[]): string | null {
|
||||
return values.find((value): value is string => Boolean(value)) ?? null
|
||||
}
|
||||
|
||||
private readIdentityFromAuthContents(contents: string): CodexAuthIdentity | null {
|
||||
let raw: Record<string, unknown>
|
||||
try {
|
||||
raw = JSON.parse(contents) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokens = this.readRecordClaim(raw, 'tokens')
|
||||
const idToken = this.normalizeField(
|
||||
this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken')
|
||||
)
|
||||
const payload = idToken ? this.parseJwtPayload(idToken) : null
|
||||
const authClaims = this.readRecordClaim(payload, 'https://api.openai.com/auth')
|
||||
const profileClaims = this.readRecordClaim(payload, 'https://api.openai.com/profile')
|
||||
|
||||
return {
|
||||
email: this.normalizeField(
|
||||
this.readStringClaim(payload, 'email') ?? this.readStringClaim(profileClaims, 'email')
|
||||
),
|
||||
providerAccountId: this.normalizeField(
|
||||
this.readStringClaim(tokens, 'account_id') ??
|
||||
this.readStringClaim(tokens, 'accountId') ??
|
||||
this.readStringClaim(authClaims, 'chatgpt_account_id') ??
|
||||
this.readStringClaim(payload, 'chatgpt_account_id')
|
||||
),
|
||||
workspaceAccountId: this.normalizeField(
|
||||
this.readStringClaim(authClaims, 'workspace_account_id') ??
|
||||
this.readStringClaim(tokens, 'account_id') ??
|
||||
this.readStringClaim(tokens, 'accountId') ??
|
||||
this.readStringClaim(payload, 'chatgpt_account_id')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private readFreshnessFromAuthContents(contents: string): number | null {
|
||||
let raw: Record<string, unknown>
|
||||
try {
|
||||
raw = JSON.parse(contents) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokens = this.readRecordClaim(raw, 'tokens')
|
||||
const idToken = this.normalizeField(
|
||||
this.readStringClaim(tokens, 'id_token') ?? this.readStringClaim(tokens, 'idToken')
|
||||
)
|
||||
const payload = idToken ? this.parseJwtPayload(idToken) : null
|
||||
return (
|
||||
this.readNumberClaim(tokens, 'expires_at') ??
|
||||
this.readNumberClaim(tokens, 'expiresAt') ??
|
||||
this.readNumberClaim(tokens, 'expiry') ??
|
||||
this.readNumberClaim(tokens, 'expires') ??
|
||||
this.readNumberClaim(payload, 'exp') ??
|
||||
this.readNumberClaim(payload, 'iat')
|
||||
)
|
||||
}
|
||||
|
||||
private parseJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
while (payload.length % 4 !== 0) {
|
||||
payload += '='
|
||||
}
|
||||
|
||||
try {
|
||||
const json = Buffer.from(payload, 'base64').toString('utf-8')
|
||||
return JSON.parse(json) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private readRecordClaim(
|
||||
value: Record<string, unknown> | null,
|
||||
key: string
|
||||
): Record<string, unknown> | null {
|
||||
const claim = value?.[key]
|
||||
if (!claim || typeof claim !== 'object' || Array.isArray(claim)) {
|
||||
return null
|
||||
}
|
||||
return claim as Record<string, unknown>
|
||||
}
|
||||
|
||||
private readStringClaim(value: Record<string, unknown> | null, key: string): string | null {
|
||||
const claim = value?.[key]
|
||||
return typeof claim === 'string' ? claim : null
|
||||
}
|
||||
|
||||
private readNumberClaim(value: Record<string, unknown> | null, key: string): number | null {
|
||||
const claim = value?.[key]
|
||||
if (typeof claim === 'number' && Number.isFinite(claim)) {
|
||||
return claim
|
||||
}
|
||||
if (typeof claim === 'string') {
|
||||
const parsed = Number(claim)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private normalizeField(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
|
||||
private safeMigrateLegacyManagedState(): void {
|
||||
try {
|
||||
this.migrateLegacyManagedStateIfNeeded()
|
||||
@@ -330,34 +612,48 @@ export class CodexRuntimeHomeService {
|
||||
writeFileAtomically(diagnosticsPath, `${existingContents}${JSON.stringify(record)}\n`)
|
||||
}
|
||||
|
||||
private captureSystemDefaultSnapshotIfNeeded(): void {
|
||||
private captureSystemDefaultSnapshot(options: { force: boolean }): void {
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
if (existsSync(snapshotPath)) {
|
||||
if (!options.force && existsSync(snapshotPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeAuthPath = this.getRuntimeAuthPath()
|
||||
if (!existsSync(runtimeAuthPath)) {
|
||||
return
|
||||
const snapshot: CodexSystemDefaultSnapshot = {
|
||||
authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null
|
||||
}
|
||||
|
||||
writeFileAtomically(snapshotPath, readFileSync(runtimeAuthPath, 'utf-8'))
|
||||
writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 })
|
||||
}
|
||||
|
||||
private restoreSystemDefaultSnapshot(): void {
|
||||
private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void {
|
||||
// Why: detect whether an external tool (e.g. `codex auth login`) overwrote
|
||||
// auth.json while a managed account was active. If so, that external login
|
||||
// becomes the new system default — skip the stale snapshot restore.
|
||||
if (this.detectExternalLoginAndUpdateSnapshot()) {
|
||||
if (options.detectExternalLogin && this.detectExternalLoginAndUpdateSnapshot()) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
if (!existsSync(snapshotPath)) {
|
||||
rmSync(this.getRuntimeAuthPath(), { force: true })
|
||||
this.lastWrittenAuthJson = null
|
||||
return
|
||||
}
|
||||
|
||||
this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8'))
|
||||
const snapshot = this.readSystemDefaultSnapshot(snapshotPath)
|
||||
if (!snapshot) {
|
||||
console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot')
|
||||
rmSync(snapshotPath, { force: true })
|
||||
rmSync(this.getRuntimeAuthPath(), { force: true })
|
||||
this.lastWrittenAuthJson = null
|
||||
return
|
||||
}
|
||||
if (snapshot.authJson === null) {
|
||||
rmSync(this.getRuntimeAuthPath(), { force: true })
|
||||
this.lastWrittenAuthJson = null
|
||||
return
|
||||
}
|
||||
this.writeRuntimeAuth(snapshot.authJson)
|
||||
}
|
||||
|
||||
// Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot().
|
||||
@@ -371,7 +667,10 @@ export class CodexRuntimeHomeService {
|
||||
|
||||
const runtimeAuthPath = this.getRuntimeAuthPath()
|
||||
if (!existsSync(runtimeAuthPath)) {
|
||||
return false
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
rmSync(snapshotPath, { force: true })
|
||||
this.lastWrittenAuthJson = null
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -392,10 +691,52 @@ export class CodexRuntimeHomeService {
|
||||
private writeRuntimeAuth(contents: string): void {
|
||||
// Why: auth.json contains sensitive credentials. Restrict to owner-only
|
||||
// so other users on a shared Linux/macOS machine cannot read it.
|
||||
if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) {
|
||||
this.ensureOwnerOnlyMode(this.getRuntimeAuthPath())
|
||||
this.lastWrittenAuthJson = contents
|
||||
return
|
||||
}
|
||||
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
|
||||
this.lastWrittenAuthJson = contents
|
||||
}
|
||||
|
||||
private fileContentsEqual(targetPath: string, contents: string): boolean {
|
||||
try {
|
||||
return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private ensureOwnerOnlyMode(targetPath: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
chmodSync(targetPath, 0o600)
|
||||
} catch {
|
||||
/* Best effort: the next atomic write will set the restrictive mode. */
|
||||
}
|
||||
}
|
||||
|
||||
private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(snapshotPath, 'utf-8')) as unknown
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
'authJson' in parsed &&
|
||||
(typeof parsed.authJson === 'string' || parsed.authJson === null)
|
||||
) {
|
||||
return parsed as CodexSystemDefaultSnapshot
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
clearSystemDefaultSnapshot(): void {
|
||||
rmSync(this.getSystemDefaultSnapshotPath(), { force: true })
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export class CodexAccountService {
|
||||
activeCodexManagedAccountId: account.id
|
||||
})
|
||||
this.safeSyncCanonicalConfigToManagedHomes()
|
||||
this.runtimeHome.clearLastWrittenAuthJson()
|
||||
this.runtimeHome.clearLastWrittenAuthJson(account.id)
|
||||
this.runtimeHome.syncForCurrentSelection()
|
||||
|
||||
// Why: the new account becomes active, so the previous active account is
|
||||
@@ -150,7 +150,7 @@ export class CodexAccountService {
|
||||
codexManagedAccounts: updatedAccounts
|
||||
})
|
||||
this.safeSyncCanonicalConfigToManagedHomes()
|
||||
this.runtimeHome.clearLastWrittenAuthJson()
|
||||
this.runtimeHome.clearLastWrittenAuthJson(accountId)
|
||||
this.runtimeHome.syncForCurrentSelection()
|
||||
|
||||
// Why: re-auth can change which actual Codex identity the managed home
|
||||
|
||||
+4
-3
@@ -277,17 +277,18 @@ function openMainWindow(): BrowserWindow {
|
||||
})
|
||||
mainWindow = window
|
||||
agentHookServer.setListener(
|
||||
({ paneKey, tabId, worktreeId, payload, receivedAt, stateStartedAt }) => {
|
||||
({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => {
|
||||
if (mainWindow?.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
mainWindow?.webContents.send('agentStatus:set', {
|
||||
...payload,
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
receivedAt,
|
||||
stateStartedAt,
|
||||
...payload
|
||||
stateStartedAt
|
||||
})
|
||||
// Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn,
|
||||
// and opencode's stays bare "OpenCode" — neither carries a working/idle
|
||||
|
||||
@@ -66,6 +66,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
ipcMain.removeHandler('repos:list')
|
||||
ipcMain.removeHandler('repos:add')
|
||||
ipcMain.removeHandler('repos:remove')
|
||||
ipcMain.removeHandler('repos:reorder')
|
||||
ipcMain.removeHandler('repos:update')
|
||||
ipcMain.removeHandler('repos:pickFolder')
|
||||
ipcMain.removeHandler('repos:pickDirectory')
|
||||
@@ -417,6 +418,22 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'repos:reorder',
|
||||
(_event, args: { orderedIds: string[] }): { status: 'applied' | 'rejected' } => {
|
||||
// Why: validate at the IPC boundary — IPC input is untrusted and a
|
||||
// permutation mismatch means the renderer's drag was stale relative to
|
||||
// a concurrent add/remove. Reject so the renderer can resync.
|
||||
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
|
||||
const applied = store.reorderRepos(ids)
|
||||
if (applied) {
|
||||
notifyReposChanged(mainWindow)
|
||||
return { status: 'applied' }
|
||||
}
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
|
||||
store.removeRepo(args.repoId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
|
||||
@@ -599,6 +599,38 @@ export class Store {
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
// Why: returns false on a stale permutation (concurrent add/remove races
|
||||
// the renderer's drag) so the caller can tell the renderer to resync rather
|
||||
// than persist an order that drops or duplicates ids.
|
||||
reorderRepos(orderedIds: string[]): boolean {
|
||||
const current = this.state.repos
|
||||
if (orderedIds.length !== current.length) {
|
||||
return false
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
for (const id of orderedIds) {
|
||||
if (typeof id !== 'string' || seen.has(id)) {
|
||||
return false
|
||||
}
|
||||
seen.add(id)
|
||||
}
|
||||
const byId = new Map<string, Repo>()
|
||||
for (const r of current) {
|
||||
byId.set(r.id, r)
|
||||
}
|
||||
const next: Repo[] = []
|
||||
for (const id of orderedIds) {
|
||||
const repo = byId.get(id)
|
||||
if (!repo) {
|
||||
return false
|
||||
}
|
||||
next.push(repo)
|
||||
}
|
||||
this.state.repos = next
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
removeRepo(id: string): void {
|
||||
this.state.repos = this.state.repos.filter((r) => r.id !== id)
|
||||
// Why: presets are repo-scoped, so removing the repo means the presets
|
||||
|
||||
@@ -33,6 +33,7 @@ const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock,
|
||||
autoUpdaterMock.checkForUpdates.mockReset()
|
||||
autoUpdaterMock.downloadUpdate.mockReset()
|
||||
autoUpdaterMock.quitAndInstall.mockReset()
|
||||
autoUpdaterMock.setFeedURL.mockClear()
|
||||
}
|
||||
|
||||
const autoUpdaterMock = {
|
||||
@@ -91,6 +92,20 @@ vi.mock('./updater-nudge', () => ({
|
||||
shouldApplyNudge: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000
|
||||
const THIRTY_SECONDS_MS = 30 * 1000
|
||||
const FRIENDLY_MESSAGE = "Couldn't reach the update server. Try again in a few minutes."
|
||||
|
||||
function makeBenignCheckFailure(message: string): void {
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('error', new Error(message))
|
||||
})
|
||||
return Promise.reject(new Error(message))
|
||||
})
|
||||
}
|
||||
|
||||
describe('updater check failure handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
@@ -105,63 +120,53 @@ describe('updater check failure handling', () => {
|
||||
isMock.dev = false
|
||||
killAllPtyMock.mockReset()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('surfaces GitHub release transition errors to user-initiated checks', async () => {
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub'))
|
||||
})
|
||||
return Promise.reject(new Error('Unable to find latest version on GitHub'))
|
||||
})
|
||||
it('surfaces GitHub release-transition failures with calmer copy and no short retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
makeBenignCheckFailure('Unable to find latest version on GitHub')
|
||||
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never)
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.waitFor(() => {
|
||||
const statuses = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
// Why: a user-initiated benign failure must show a visible error. Silently
|
||||
// sending 'idle' (or 'not-available') makes the button look broken.
|
||||
expect(statuses).toContainEqual(
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
userInitiated: true,
|
||||
message: expect.stringContaining('GitHub may be temporarily unavailable')
|
||||
})
|
||||
)
|
||||
expect(statuses).not.toContainEqual(
|
||||
expect.objectContaining({ state: 'not-available', userInitiated: true })
|
||||
)
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const statuses = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
|
||||
expect(statuses).toContainEqual({ state: 'checking', userInitiated: true })
|
||||
expect(statuses).toContainEqual(
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
userInitiated: true,
|
||||
message: FRIENDLY_MESSAGE
|
||||
})
|
||||
)
|
||||
expect(statuses).not.toContainEqual(
|
||||
expect.objectContaining({ message: expect.stringContaining('Unable to find latest version') })
|
||||
)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces missing latest-mac.yml to user-initiated checks', async () => {
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValueOnce(undefined).mockImplementationOnce(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit(
|
||||
'error',
|
||||
new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404')
|
||||
)
|
||||
})
|
||||
return Promise.reject(
|
||||
new Error('Cannot find channel "latest-mac.yml" update info: HttpError: 404')
|
||||
)
|
||||
})
|
||||
it('surfaces missing latest-mac.yml to user-initiated checks with calmer copy', async () => {
|
||||
makeBenignCheckFailure('Cannot find channel "latest-mac.yml" update info: HttpError: 404')
|
||||
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never)
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.waitFor(() => {
|
||||
const statuses = sendMock.mock.calls
|
||||
@@ -171,7 +176,7 @@ describe('updater check failure handling', () => {
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
userInitiated: true,
|
||||
message: expect.stringContaining('GitHub may be temporarily unavailable')
|
||||
message: FRIENDLY_MESSAGE
|
||||
})
|
||||
)
|
||||
expect(statuses).not.toContainEqual(
|
||||
@@ -180,30 +185,32 @@ describe('updater check failure handling', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('silently drops background benign failures to idle', async () => {
|
||||
// Why: background checks must stay quiet; only user-initiated clicks get
|
||||
// an error card. This prevents noisy nag during a release transition.
|
||||
autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('error', new Error('Unable to find latest version on GitHub'))
|
||||
})
|
||||
return Promise.reject(new Error('Unable to find latest version on GitHub'))
|
||||
})
|
||||
it('silently drops background benign failures to idle and waits for the hourly retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
makeBenignCheckFailure('Unable to find latest version on GitHub')
|
||||
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdates } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never)
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
checkForUpdates()
|
||||
await vi.waitFor(() => {
|
||||
const statuses = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
expect(statuses).toContainEqual({ state: 'idle' })
|
||||
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const statuses = sendMock.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
|
||||
expect(statuses).toContainEqual({ state: 'idle' })
|
||||
expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' }))
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(THIRTY_SECONDS_MS)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(ONE_HOUR_MS - THIRTY_SECONDS_MS)
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('updater', () => {
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
userInitiated: true,
|
||||
message: expect.stringContaining('GitHub may be temporarily unavailable')
|
||||
message: expect.stringContaining("Couldn't reach the update server")
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
+1
-1
@@ -217,7 +217,7 @@ async function sendCheckFailureStatus(message: string, userInitiated?: boolean):
|
||||
// prefixes "Could not check for updates." and Settings prefixes
|
||||
// "Update check failed.", so the message here only carries the
|
||||
// actionable cause.
|
||||
sendErrorStatus('GitHub may be temporarily unavailable. Try again in a minute.', true)
|
||||
sendErrorStatus("Couldn't reach the update server. Try again in a few minutes.", true)
|
||||
} else {
|
||||
sendStatus({ state: 'idle' })
|
||||
}
|
||||
|
||||
@@ -389,6 +389,7 @@ export type PreloadApi = {
|
||||
kind?: 'git' | 'folder'
|
||||
}) => Promise<{ repo: Repo } | { error: string }>
|
||||
remove: (args: { repoId: string }) => Promise<void>
|
||||
reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }>
|
||||
update: (args: {
|
||||
repoId: string
|
||||
updates: Partial<
|
||||
|
||||
@@ -287,6 +287,9 @@ const api = {
|
||||
|
||||
remove: (args: { repoId: string }): Promise<void> => ipcRenderer.invoke('repos:remove', args),
|
||||
|
||||
reorder: (args: { orderedIds: string[] }): Promise<{ status: 'applied' | 'rejected' }> =>
|
||||
ipcRenderer.invoke('repos:reorder', args),
|
||||
|
||||
update: (args: { repoId: string; updates: Record<string, unknown> }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('repos:update', args),
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { RelayAgentHookServer } from './agent-hook-server'
|
||||
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
|
||||
|
||||
describe('RelayAgentHookServer', () => {
|
||||
let dir: string
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
env: 'remote',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
|
||||
})
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
const envelope = forward.mock.calls[0][0]
|
||||
expect(envelope.source).toBe('claude')
|
||||
expect(envelope.paneKey).toBe('tab-1:0')
|
||||
expect(envelope.tabId).toBe('tab-1')
|
||||
expect(envelope.connectionId).toBeNull()
|
||||
expect(envelope.payload.state).toBe('working')
|
||||
expect(envelope.payload.prompt).toBe('hi')
|
||||
// Why: the relay forwards body env/version verbatim so Orca's existing
|
||||
// warn-once cross-build / dev-vs-prod diagnostics still fire on remote.
|
||||
expect(envelope.env).toBe('remote')
|
||||
expect(envelope.version).toBe('1')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects requests with the wrong bearer token (403)', async () => {
|
||||
const forward = vi.fn()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port } = server.getCoordinates()
|
||||
const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': 'wrong'
|
||||
},
|
||||
body: '{}'
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays cached payloads on demand', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
env: 'remote',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cache me' }
|
||||
})
|
||||
})
|
||||
forward.mockClear()
|
||||
const replayed = server.replayCachedPayloadsForPanes()
|
||||
expect(replayed).toBe(1)
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
expect(forward.mock.calls[0][0].payload.prompt).toBe('cache me')
|
||||
// Why: replay must preserve the wire envelope's env/version (and source)
|
||||
// so Orca's warn-once cross-build / dev-vs-prod diagnostics fire on
|
||||
// replayed events the same as on live POST events.
|
||||
expect(forward.mock.calls[0][0].source).toBe('claude')
|
||||
expect(forward.mock.calls[0][0].env).toBe('remote')
|
||||
expect(forward.mock.calls[0][0].version).toBe('1')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not replay paneKeys after clearPaneState', async () => {
|
||||
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const { port, token } = server.getCoordinates()
|
||||
await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: 'tab-1:0',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' }
|
||||
})
|
||||
})
|
||||
server.clearPaneState('tab-1:0')
|
||||
forward.mockClear()
|
||||
const replayed = server.replayCachedPayloadsForPanes()
|
||||
expect(replayed).toBe(0)
|
||||
expect(forward).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes ORCA_AGENT_HOOK_* env vars after start', async () => {
|
||||
const forward = vi.fn()
|
||||
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
|
||||
await server.start()
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
expect(env.ORCA_AGENT_HOOK_PORT).toMatch(/^\d+$/)
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy()
|
||||
expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote')
|
||||
expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1')
|
||||
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts
|
||||
// a loopback HTTP server (same shape as Orca's main-process server: bind
|
||||
// 127.0.0.1:0, bearer-token auth, /hook/<source> routing) and forwards every
|
||||
// parsed payload via a callback so `relay.ts` can re-emit it as an
|
||||
// `agent.hook` JSON-RPC notification across the existing SSH channel.
|
||||
//
|
||||
// Per-instance state (warn-once Sets, last-status cache, last-prompt /
|
||||
// last-tool caches) lives on `HookListenerState`. The cache is bounded to one
|
||||
// entry per paneKey — see docs/design/agent-status-over-ssh.md §5 (Path 3,
|
||||
// request-driven replay) for the rationale.
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
|
||||
import {
|
||||
clearAllListenerCaches,
|
||||
clearPaneCacheState,
|
||||
createHookListenerState,
|
||||
getEndpointFileName,
|
||||
HOOK_REQUEST_SLOWLORIS_MS,
|
||||
normalizeHookPayload,
|
||||
readRequestBody,
|
||||
resolveHookSource,
|
||||
writeEndpointFile,
|
||||
type AgentHookEventPayload,
|
||||
type HookListenerState
|
||||
} from '../shared/agent-hook-listener'
|
||||
import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay'
|
||||
|
||||
export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void
|
||||
|
||||
// Why: relay's userData equivalent. Lives under $HOME so each user on a
|
||||
// shared dev box gets their own dir, owned 0o700. Mirrors RELAY_REMOTE_DIR
|
||||
// from `ssh-relay-deploy.ts` but stays local to this module — the hook
|
||||
// server is the only consumer.
|
||||
const RELAY_HOOKS_DIR_NAME = '.orca-relay'
|
||||
const RELAY_HOOKS_SUBDIR = 'agent-hooks'
|
||||
|
||||
function defaultEndpointDir(): string {
|
||||
return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR)
|
||||
}
|
||||
|
||||
export type RelayHookServerOptions = {
|
||||
/** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */
|
||||
endpointDir?: string
|
||||
/** Env tag forwarded into hook payloads (warn-once cross-build diagnostic).
|
||||
* Defaults to "remote" — distinct from Orca's local 'production'/'development'. */
|
||||
env?: string
|
||||
/** Called once per parsed payload. The relay wires this to
|
||||
* `dispatcher.notify('agent.hook', envelope)`. */
|
||||
forward: RelayHookForward
|
||||
}
|
||||
|
||||
export class RelayAgentHookServer {
|
||||
private server: ReturnType<typeof createServer> | null = null
|
||||
private port = 0
|
||||
private token = ''
|
||||
private env: string
|
||||
private endpointDir: string
|
||||
private endpointFilePath: string
|
||||
private endpointFileWritten = false
|
||||
private state: HookListenerState = createHookListenerState()
|
||||
// Why: the shared `HookListenerState.lastStatusByPaneKey` cache only stores
|
||||
// `AgentHookEventPayload` (no wire-envelope fields). Replay must still emit
|
||||
// the original `source`/`env`/`version` so Orca's warn-once diagnostics fire
|
||||
// identically to the live POST path. Keep this as a per-instance sidecar map
|
||||
// so the shared listener type stays unchanged. Invariant: every key present
|
||||
// in `state.lastStatusByPaneKey` must also be present here — populated and
|
||||
// cleared in lockstep on the live POST path, clearPaneState, and stop().
|
||||
private lastEnvelopeMetaByPaneKey: Map<
|
||||
string,
|
||||
{ source: AgentHookSource; env?: string; version?: string }
|
||||
> = new Map()
|
||||
private forward: RelayHookForward
|
||||
|
||||
constructor(options: RelayHookServerOptions) {
|
||||
this.env = options.env ?? 'remote'
|
||||
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
|
||||
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
|
||||
this.forward = options.forward
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) {
|
||||
return
|
||||
}
|
||||
this.token = randomUUID()
|
||||
this.endpointFileWritten = false
|
||||
this.server = createServer((req, res) => this.handleRequest(req, res))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onStartupError = (err: Error): void => {
|
||||
this.server?.off('listening', onListening)
|
||||
reject(err)
|
||||
}
|
||||
const onListening = (): void => {
|
||||
this.server?.off('error', onStartupError)
|
||||
this.server?.on('error', (err) => {
|
||||
process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`)
|
||||
})
|
||||
const address = this.server!.address()
|
||||
if (address && typeof address === 'object') {
|
||||
this.port = address.port
|
||||
}
|
||||
this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, {
|
||||
port: this.port,
|
||||
token: this.token,
|
||||
env: this.env,
|
||||
version: ORCA_HOOK_PROTOCOL_VERSION
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
this.server!.once('error', onStartupError)
|
||||
// Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only —
|
||||
// the agent CLI inside the same remote box reaches us via curl
|
||||
// 127.0.0.1:PORT; nobody outside the box can.
|
||||
this.server!.listen(0, '127.0.0.1', onListening)
|
||||
})
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.server?.close()
|
||||
this.server = null
|
||||
this.port = 0
|
||||
this.token = ''
|
||||
this.endpointFileWritten = false
|
||||
clearAllListenerCaches(this.state)
|
||||
this.lastEnvelopeMetaByPaneKey.clear()
|
||||
}
|
||||
|
||||
/** Request-driven replay: walks the per-paneKey last-payload cache and
|
||||
* forwards each entry as a fresh notification. Called after Orca has
|
||||
* re-wired its `agent.hook` handler on the new mux post-`--connect`.
|
||||
* The relay-driver issues the replay forwards BEFORE returning from the
|
||||
* request handler so the response strictly trails all replayed
|
||||
* notifications on the dispatcher's single write callback. */
|
||||
replayCachedPayloadsForPanes(): number {
|
||||
let count = 0
|
||||
for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) {
|
||||
const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey)
|
||||
// Why: invariant — every paneKey in the shared status cache is populated
|
||||
// in lockstep with `lastEnvelopeMetaByPaneKey`. If meta is missing,
|
||||
// something has drifted; skip rather than fall back to a guessed source
|
||||
// that would mis-tag the event downstream.
|
||||
if (!meta) {
|
||||
continue
|
||||
}
|
||||
this.forwardEvent(event, meta.source, meta.env, meta.version)
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** Drop a paneKey's cached entries on PTY exit so a terminated pane never
|
||||
* resurfaces as a ghost event on a later reconnect. Symmetric with the
|
||||
* local server's clearPaneState on PTY teardown. */
|
||||
clearPaneState(paneKey: string): void {
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.lastEnvelopeMetaByPaneKey.delete(paneKey)
|
||||
}
|
||||
|
||||
/** Env vars to inject into every relay-spawned PTY so the hook script /
|
||||
* in-process plugin POSTs to this loopback server. */
|
||||
buildPtyEnv(): Record<string, string> {
|
||||
if (this.port <= 0 || !this.token) {
|
||||
return {}
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
ORCA_AGENT_HOOK_PORT: String(this.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: this.token,
|
||||
ORCA_AGENT_HOOK_ENV: this.env,
|
||||
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
|
||||
}
|
||||
if (this.endpointFileWritten) {
|
||||
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePath
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Test-only / diagnostics accessor. */
|
||||
getCoordinates(): { port: number; token: string; endpointFilePath: string } {
|
||||
return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath }
|
||||
}
|
||||
|
||||
// ─── Private ──────────────────────────────────────────────────────
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
|
||||
res.writeHead(403)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
|
||||
req.destroy()
|
||||
})
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
const source = resolveHookSource(pathname)
|
||||
if (!source) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const event = normalizeHookPayload(this.state, source, body, this.env)
|
||||
if (event) {
|
||||
this.state.lastStatusByPaneKey.set(event.paneKey, event)
|
||||
// TODO: once normalizeHookPayload returns validated env/version, drop
|
||||
// bodyEnv/bodyVersion and source those from the listener result instead.
|
||||
const env = this.bodyEnv(body)
|
||||
const version = this.bodyVersion(body)
|
||||
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
||||
this.forwardEvent(event, source, env, version)
|
||||
}
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch {
|
||||
// Why: agent hooks must fail open — return success on parse / size /
|
||||
// timeout errors so a buggy agent script never blocks the agent run.
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
private forwardEvent(
|
||||
event: AgentHookEventPayload,
|
||||
source: AgentHookSource,
|
||||
env?: string,
|
||||
version?: string
|
||||
): void {
|
||||
const envelope: AgentHookRelayEnvelope = {
|
||||
source,
|
||||
paneKey: event.paneKey,
|
||||
tabId: event.tabId,
|
||||
worktreeId: event.worktreeId,
|
||||
connectionId: null,
|
||||
env,
|
||||
version,
|
||||
payload: event.payload
|
||||
}
|
||||
this.forward(envelope)
|
||||
}
|
||||
|
||||
private bodyEnv(body: unknown): string | undefined {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return undefined
|
||||
}
|
||||
const v = (body as Record<string, unknown>).env
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
|
||||
private bodyVersion(body: unknown): string | undefined {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return undefined
|
||||
}
|
||||
const v = (body as Record<string, unknown>).version
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
/* eslint-disable max-lines -- Why: the GH item dialog keeps its header, conversation, files, and checks tabs co-located so the read-only PR/Issue surface stays in one place while this view evolves. */
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, {
|
||||
Suspense,
|
||||
lazy,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore
|
||||
} from 'react'
|
||||
import {
|
||||
AlignJustify,
|
||||
ArrowDown,
|
||||
@@ -509,6 +518,23 @@ type WorkItemDetailsCacheEntry = {
|
||||
}
|
||||
const workItemDetailsCache = new Map<string, WorkItemDetailsCacheEntry>()
|
||||
|
||||
// Why: drawers subscribe via useSyncExternalStore so reopening a cached item
|
||||
// paints synchronously on first render. Stability of the snapshot relies on
|
||||
// every cache write replacing the entry object identity (delete+set), which
|
||||
// touchWorkItemDetailsCache already does.
|
||||
const workItemDetailsCacheListeners = new Set<() => void>()
|
||||
function subscribeWorkItemDetailsCache(listener: () => void): () => void {
|
||||
workItemDetailsCacheListeners.add(listener)
|
||||
return () => {
|
||||
workItemDetailsCacheListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
function notifyWorkItemDetailsCache(): void {
|
||||
for (const listener of workItemDetailsCacheListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkItemDetailsCacheKey(args: {
|
||||
repoPath: string
|
||||
issueSourcePreference: string | undefined
|
||||
@@ -532,13 +558,20 @@ function touchWorkItemDetailsCache(key: string, entry: WorkItemDetailsCacheEntry
|
||||
}
|
||||
workItemDetailsCache.delete(oldest)
|
||||
}
|
||||
notifyWorkItemDetailsCache()
|
||||
}
|
||||
|
||||
// Why: exposed so mutation handlers (in this file and elsewhere) can drop a
|
||||
// stale entry after a successful local mutation. Cross-window invalidation
|
||||
// arrives via the `gh:workItemMutated` event listener installed below.
|
||||
export function invalidateWorkItemDetailsCacheForKey(key: string): void {
|
||||
workItemDetailsCache.delete(key)
|
||||
// Why: bump generation so an in-flight fetch launched before this exact-key
|
||||
// invalidation will not write its stale result back into the cache.
|
||||
workItemDetailsCacheGeneration += 1
|
||||
const existed = workItemDetailsCache.delete(key)
|
||||
if (existed) {
|
||||
notifyWorkItemDetailsCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: monotonically increases on every invalidation so an in-flight refetch
|
||||
@@ -559,11 +592,16 @@ function invalidateWorkItemDetailsCacheByMatch(args: {
|
||||
workItemDetailsCacheGeneration += 1
|
||||
const suffix = `\0${args.type}\0${args.number}`
|
||||
const prefix = `${args.repoPath}\0`
|
||||
let removed = false
|
||||
for (const key of Array.from(workItemDetailsCache.keys())) {
|
||||
if (key.startsWith(prefix) && key.endsWith(suffix)) {
|
||||
workItemDetailsCache.delete(key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
notifyWorkItemDetailsCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: install once at module load — every dialog instance shares the cache,
|
||||
@@ -2282,7 +2320,7 @@ function GHCommentComposer({
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [autoGrow, body, repoPath, issueNumber, onCommentAdded])
|
||||
}, [autoGrow, body, repoPath, issueNumber, itemType, onCommentAdded])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -2397,9 +2435,6 @@ export default function GitHubItemDialog({
|
||||
onClose
|
||||
}: GitHubItemDialogProps): React.JSX.Element {
|
||||
const [tab, setTab] = useState<ItemDialogTab>('conversation')
|
||||
const [details, setDetails] = useState<GitHubWorkItemDetails | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [localState, setLocalState] = useState<GitHubWorkItem['state']>(workItem?.state ?? 'open')
|
||||
const [localLabels, setLocalLabels] = useState<string[]>(workItem?.labels ?? [])
|
||||
const [diffViewMode, setDiffViewMode] = useState<DiffViewMode>('flat')
|
||||
@@ -2439,7 +2474,6 @@ export default function GitHubItemDialog({
|
||||
}
|
||||
}, [workItemId, workItemState, workItemLabels])
|
||||
|
||||
const requestIdRef = useRef(0)
|
||||
// Why: track comments added optimistically before the detail fetch resolves
|
||||
// so they can be merged into the fetch result instead of being overwritten.
|
||||
const optimisticCommentsRef = useRef<PRComment[]>([])
|
||||
@@ -2477,22 +2511,81 @@ export default function GitHubItemDialog({
|
||||
}
|
||||
}, [workItem])
|
||||
|
||||
// Why: subscribe to the module-level cache so reopening a cached item
|
||||
// paints synchronously on first render. getSnapshot returns the entry
|
||||
// object directly — touchWorkItemDetailsCache writes always replace entry
|
||||
// identity (delete+set), so Map.get is referentially stable between writes.
|
||||
const cachedEntry = useSyncExternalStore(
|
||||
subscribeWorkItemDetailsCache,
|
||||
useCallback(
|
||||
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined),
|
||||
[detailsCacheKey]
|
||||
)
|
||||
)
|
||||
|
||||
// Why: bumped by appendOptimisticComment on cold open (no cached details
|
||||
// yet) so the details memo re-runs and surfaces the optimistic comment via
|
||||
// the loading-shell fallback. Without this, the comment would sit in the
|
||||
// ref alone and not render until the in-flight fetch lands. The cache
|
||||
// notify path handles the warm case.
|
||||
const [optimisticTick, setOptimisticTick] = useState(0)
|
||||
|
||||
// Why: merge optimistic comments into the cached details. Keyed off
|
||||
// cachedEntry identity (stable) rather than the optimistic ref array (a
|
||||
// fresh array each render) to avoid unnecessary recomputation. Cache
|
||||
// notifications after optimistic writes will re-render this anyway.
|
||||
const details = useMemo<GitHubWorkItemDetails | null>(() => {
|
||||
const cachedDetails = cachedEntry?.details ?? null
|
||||
const opt = optimisticCommentsRef.current
|
||||
if (!cachedDetails) {
|
||||
// Why: details may still be loading on a cold open — surface optimistic
|
||||
// comments via a minimal shell so a comment posted before the fetch
|
||||
// resolves isn't held invisibly in ref-land.
|
||||
if (opt.length > 0 && workItem) {
|
||||
return { item: workItem, body: '', comments: [...opt] }
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (opt.length === 0) {
|
||||
return cachedDetails
|
||||
}
|
||||
const ids = new Set(cachedDetails.comments.map((c) => c.id))
|
||||
const missing = opt.filter((c) => !ids.has(c.id))
|
||||
if (missing.length === 0) {
|
||||
return cachedDetails
|
||||
}
|
||||
return { ...cachedDetails, comments: [...cachedDetails.comments, ...missing] }
|
||||
// Why: optimisticTick is the rerender signal for cold-open writes — the
|
||||
// memo reads optimisticCommentsRef.current (a ref, no subscription), so
|
||||
// bumping the tick is what forces this memo to re-run. The lint flags it
|
||||
// as "unnecessary" because it's not referenced in the body, but removing
|
||||
// it would silently break the cold-open optimistic-shell path.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cachedEntry, workItem, optimisticTick])
|
||||
|
||||
const loading = !!cachedEntry?.pending && !cachedEntry?.details
|
||||
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
|
||||
|
||||
// Why: if a cross-window mutation invalidates the open drawer's entry
|
||||
// (cachedEntry becomes undefined while workItem is still set), the main
|
||||
// fetch effect won't re-run because its deps haven't changed. Bump a local
|
||||
// tick so the fetch effect fires a refetch in that case.
|
||||
const [refetchTick, setRefetchTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (workItem && detailsCacheKey && !cachedEntry) {
|
||||
setRefetchTick((n) => n + 1)
|
||||
}
|
||||
}, [workItem, detailsCacheKey, cachedEntry])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workItem || !repoPath || !detailsCacheKey) {
|
||||
setDetails(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
// Why: if the user clicks through several rows quickly, discard stale
|
||||
// responses by tagging each request with a monotonic id and only applying
|
||||
// results whose id matches the latest one.
|
||||
requestIdRef.current += 1
|
||||
const requestId = requestIdRef.current
|
||||
// Why: only clear optimistic comments when switching to a genuinely
|
||||
// different item. When reopening the same item (close → reopen), the
|
||||
// gh API's 60s response cache will return stale data that omits the
|
||||
// just-posted comment — preserving the optimistic ref lets the merge
|
||||
// logic below re-attach it to the stale response.
|
||||
// logic above re-attach it to the stale response.
|
||||
if (workItem.id !== prevItemIdRef.current) {
|
||||
optimisticCommentsRef.current = []
|
||||
}
|
||||
@@ -2503,28 +2596,6 @@ export default function GitHubItemDialog({
|
||||
const now = Date.now()
|
||||
const hasFreshData = cached?.details && now - cached.fetchedAt <= WORK_ITEM_DETAILS_FRESH_MS
|
||||
|
||||
// Why: paint cached data immediately when we have it so reopen feels
|
||||
// instant. Only fall back to a blocking spinner when there's nothing to
|
||||
// show. Optimistic comments still merge below for both paths.
|
||||
if (cached?.details) {
|
||||
const opt = optimisticCommentsRef.current
|
||||
let painted = cached.details
|
||||
if (opt.length > 0) {
|
||||
const ids = new Set(painted.comments.map((c) => c.id))
|
||||
const missing = opt.filter((c) => !ids.has(c.id))
|
||||
if (missing.length > 0) {
|
||||
painted = { ...painted, comments: [...painted.comments, ...missing] }
|
||||
}
|
||||
}
|
||||
setDetails(painted)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
} else {
|
||||
setDetails(null)
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
if (hasFreshData) {
|
||||
return
|
||||
}
|
||||
@@ -2557,13 +2628,16 @@ export default function GitHubItemDialog({
|
||||
inflight
|
||||
.then((result) => {
|
||||
const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
if (invalidatedMidFlight) {
|
||||
// Why: entry was deliberately dropped; do not recreate it. If the
|
||||
// entry still exists (later open repopulated it) leave it alone too.
|
||||
return
|
||||
}
|
||||
// Why: 404/unauthorized must not overwrite valid cached data. When the
|
||||
// IPC resolves to null and we already have cached details, keep the
|
||||
// stale data — only blank entries get the null payload.
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
if (invalidatedMidFlight) {
|
||||
// Skip cache write entirely — the entry was deliberately dropped.
|
||||
} else if (result === null && prev?.details) {
|
||||
if (result === null && prev?.details) {
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: prev.details,
|
||||
fetchedAt: prev.fetchedAt,
|
||||
@@ -2576,53 +2650,24 @@ export default function GitHubItemDialog({
|
||||
error: undefined
|
||||
})
|
||||
}
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
// Why: merge any comments the user posted optimistically while the
|
||||
// detail fetch was in-flight, using id to avoid duplicates.
|
||||
const opt = optimisticCommentsRef.current
|
||||
let merged = result
|
||||
if (opt.length > 0 && merged) {
|
||||
const fetchedIds = new Set(merged.comments.map((c: PRComment) => c.id))
|
||||
const missing = opt.filter((c) => !fetchedIds.has(c.id))
|
||||
if (missing.length > 0) {
|
||||
merged = { ...merged, comments: [...merged.comments, ...missing] }
|
||||
}
|
||||
}
|
||||
if (merged !== null || !cached?.details) {
|
||||
setDetails(merged)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load details'
|
||||
const invalidatedMidFlight = workItemDetailsCacheGeneration !== launchedAtGeneration
|
||||
if (invalidatedMidFlight) {
|
||||
return
|
||||
}
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
// Why: stale-on-error — keep cached data if we have it, drop the
|
||||
// pending promise so the next open can retry. Only surface the
|
||||
// blocking error when nothing is cached. If invalidated mid-flight,
|
||||
// don't restore stale data into the now-empty entry.
|
||||
if (!invalidatedMidFlight) {
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: prev?.details ?? null,
|
||||
fetchedAt: prev?.fetchedAt ?? 0,
|
||||
error: message
|
||||
})
|
||||
}
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
if (!prev?.details) {
|
||||
setError(message)
|
||||
}
|
||||
// blocking error when nothing is cached.
|
||||
touchWorkItemDetailsCache(detailsCacheKey, {
|
||||
details: prev?.details ?? null,
|
||||
fetchedAt: prev?.fetchedAt ?? 0,
|
||||
error: message
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [repoPath, workItem, detailsCacheKey])
|
||||
}, [repoPath, workItem, detailsCacheKey, refetchTick])
|
||||
|
||||
const Icon = workItem?.type === 'pr' ? GitPullRequest : CircleDot
|
||||
const body = details?.body ?? ''
|
||||
@@ -2636,25 +2681,11 @@ export default function GitHubItemDialog({
|
||||
// that overwrites the optimistic comment. The next dialog open (after
|
||||
// cache expiry) will pick up the server-confirmed version.
|
||||
optimisticCommentsRef.current.push(comment)
|
||||
setDetails((prev) => {
|
||||
if (prev) {
|
||||
return { ...prev, comments: [...prev.comments, comment] }
|
||||
}
|
||||
if (!workItem) {
|
||||
return prev
|
||||
}
|
||||
// Why: details may still be loading — create a minimal shell
|
||||
// so the optimistic comment isn't silently dropped.
|
||||
return {
|
||||
item: workItem,
|
||||
body: '',
|
||||
comments: [comment]
|
||||
}
|
||||
})
|
||||
// Why: keep the module-level cache in sync so a reopen paints the new
|
||||
// comment without waiting on a refetch. Mark fetchedAt as stale (0) so
|
||||
// the next open still triggers a background refresh to pick up
|
||||
// server-side fields like reaction groups or thread bindings.
|
||||
// Why: write through the module-level cache so subscribers (this
|
||||
// drawer plus any concurrent ones on the same item) re-render with the
|
||||
// optimistic comment. Mark fetchedAt as stale (0) so the next open
|
||||
// still triggers a background refresh to pick up server-side fields
|
||||
// like reaction groups or thread bindings.
|
||||
if (detailsCacheKey) {
|
||||
const prev = workItemDetailsCache.get(detailsCacheKey)
|
||||
if (prev?.details) {
|
||||
@@ -2665,11 +2696,17 @@ export default function GitHubItemDialog({
|
||||
fetchedAt: 0,
|
||||
error: undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: when the cache has no details yet (still loading), no cache
|
||||
// write/notify fires above. Bump local state so the details memo
|
||||
// re-runs and surfaces the optimistic comment via the loading-shell
|
||||
// fallback instead of holding it invisibly in the ref.
|
||||
setOptimisticTick((n) => n + 1)
|
||||
},
|
||||
[workItem, detailsCacheKey]
|
||||
[detailsCacheKey]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -287,10 +287,26 @@ export function useDiffCommentDecorator({
|
||||
// stale zone ids from a dead editor. The diff effect below deliberately
|
||||
// has no cleanup so comment-only changes don't cause a full zone
|
||||
// rebuild; this cleanup is the single place we reset zone tracking.
|
||||
for (const entry of zones.values()) {
|
||||
entry.root.unmount()
|
||||
}
|
||||
//
|
||||
// Why defer the unmount: this cleanup can run inside React's commit work
|
||||
// loop (e.g. when an editor is disposed during a parent render and the
|
||||
// dispose listener's setState triggers a re-render that re-runs this
|
||||
// effect). A synchronous root.unmount() in that window produces React
|
||||
// 19's "Attempted to synchronously unmount a root while React was
|
||||
// already rendering" warning. queueMicrotask lands the unmount at the
|
||||
// end of the current task, before any next render, with no visible
|
||||
// delay. Clear `zones` synchronously so a subsequent editor mount sees
|
||||
// empty bookkeeping immediately. This matches the deferred unmount in
|
||||
// the diff-pass effect below.
|
||||
const rootsToUnmount = Array.from(zones.values(), (z) => z.root)
|
||||
zones.clear()
|
||||
if (rootsToUnmount.length > 0) {
|
||||
queueMicrotask(() => {
|
||||
for (const root of rootsToUnmount) {
|
||||
root.unmount()
|
||||
}
|
||||
})
|
||||
}
|
||||
// Why: editor went away — drop both the in-flight scroll request and
|
||||
// the resolver closure (which captured the now-disposed editor).
|
||||
pendingScrollRef.current = null
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ChecksList,
|
||||
PRCommentsList
|
||||
} from './checks-helpers'
|
||||
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
|
||||
import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types'
|
||||
|
||||
export default function ChecksPanel(): React.JSX.Element {
|
||||
@@ -79,6 +80,21 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
? (gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown')
|
||||
: 'unknown'
|
||||
|
||||
// Why: select only timestamps (not whole cache records) so the entry-refresh
|
||||
// effect doesn't re-run on every cache mutation. See
|
||||
// docs/refresh-on-checks-tab.md.
|
||||
const prFetchedAt = useAppStore((s) =>
|
||||
prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined
|
||||
)
|
||||
const checksCacheKey = repo && prNumber ? `${repo.path}::pr-checks::${prNumber}` : ''
|
||||
const commentsCacheKey = repo && prNumber ? `${repo.path}::pr-comments::${prNumber}` : ''
|
||||
const checksFetchedAt = useAppStore((s) =>
|
||||
checksCacheKey ? s.checksCache[checksCacheKey]?.fetchedAt : undefined
|
||||
)
|
||||
const commentsFetchedAt = useAppStore((s) =>
|
||||
commentsCacheKey ? s.commentsCache[commentsCacheKey]?.fetchedAt : undefined
|
||||
)
|
||||
|
||||
// Fetch PR data when the active worktree/branch changes.
|
||||
// Why: pass linkedPR so worktrees created from a PR (whose new local branch
|
||||
// differs from the PR's head ref) resolve via the number-based fallback.
|
||||
@@ -240,9 +256,41 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
linkedPRNumber: linkedPR
|
||||
})
|
||||
if (refreshedPR) {
|
||||
// Why: call fetchPRChecks directly with the refreshed PR's headSha so
|
||||
// we don't pass the stale headSha captured by `fetchChecks`'s closure
|
||||
// before the PR refresh completed (covers external force-pushes and
|
||||
// PR-number changes).
|
||||
const refreshedChecks = fetchPRChecks(
|
||||
repo.path,
|
||||
refreshedPR.number,
|
||||
branch,
|
||||
refreshedPR.headSha,
|
||||
{ force: true }
|
||||
).then(
|
||||
(result) => {
|
||||
setChecks(result)
|
||||
const signature = JSON.stringify(
|
||||
result.map((c) => `${c.name}:${c.status}:${c.conclusion}`)
|
||||
)
|
||||
pollIntervalRef.current =
|
||||
signature === prevChecksRef.current
|
||||
? Math.min(pollIntervalRef.current * 2, 120_000)
|
||||
: 30_000
|
||||
prevChecksRef.current = signature
|
||||
},
|
||||
(err) => {
|
||||
console.warn('Failed to fetch PR checks:', err)
|
||||
setChecks([])
|
||||
}
|
||||
)
|
||||
setChecksLoading(true)
|
||||
const refreshedComments = fetchComments({
|
||||
force: true,
|
||||
prNumberOverride: refreshedPR.number
|
||||
})
|
||||
await Promise.all([
|
||||
fetchChecks({ force: true, prNumberOverride: refreshedPR.number }),
|
||||
fetchComments({ force: true, prNumberOverride: refreshedPR.number })
|
||||
refreshedChecks.finally(() => setChecksLoading(false)),
|
||||
refreshedComments
|
||||
])
|
||||
} else {
|
||||
setChecks([])
|
||||
@@ -251,7 +299,49 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}, [repo, branch, linkedPR, fetchPRForBranch, fetchChecks, fetchComments])
|
||||
}, [repo, branch, linkedPR, fetchPRForBranch, fetchPRChecks, fetchComments])
|
||||
|
||||
// Why: force a freshness check on each "entry" into the Checks tab so PRs
|
||||
// opened outside Orca, externally force-pushed heads, and stale checks/comments
|
||||
// appear without waiting for the cache TTL. The grace window suppresses
|
||||
// duplicate fetches from rapid show/hide toggles. See
|
||||
// docs/refresh-on-checks-tab.md.
|
||||
const entryKey =
|
||||
isPanelVisible && repo && !isFolder && branch
|
||||
? `${activeWorktreeId ?? ''}::${repo.path}::${branch}`
|
||||
: ''
|
||||
const lastEntryKeyRef = useRef<string>('')
|
||||
useEffect(() => {
|
||||
if (!entryKey) {
|
||||
// Resetting on hide is required so reopening the panel on the same PR
|
||||
// re-evaluates freshness (a prevKey !== currentKey check alone would miss
|
||||
// close-and-reopen of the same PR).
|
||||
lastEntryKeyRef.current = ''
|
||||
return
|
||||
}
|
||||
if (lastEntryKeyRef.current === entryKey) {
|
||||
return
|
||||
}
|
||||
lastEntryKeyRef.current = entryKey
|
||||
|
||||
const stale = shouldEntryRefresh({
|
||||
prFetchedAt,
|
||||
checksFetchedAt,
|
||||
commentsFetchedAt,
|
||||
prNumber,
|
||||
now: Date.now(),
|
||||
graceMs: ENTRY_REFRESH_GRACE_MS
|
||||
})
|
||||
if (!stale) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset polling attention state so the forced fetch's signature establishes
|
||||
// a fresh baseline rather than colliding with the previous PR's backoff.
|
||||
pollIntervalRef.current = 30_000
|
||||
prevChecksRef.current = ''
|
||||
void handleRefresh()
|
||||
}, [entryKey, prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, handleRefresh])
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
if (!pr) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
describe('shouldEntryRefresh', () => {
|
||||
it('refreshes when PR cache is missing (cold start, no PR known)', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: undefined,
|
||||
checksFetchedAt: undefined,
|
||||
commentsFetchedAt: undefined,
|
||||
prNumber: null,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes when cached null PR result is older than the grace window', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1,
|
||||
checksFetchedAt: undefined,
|
||||
commentsFetchedAt: undefined,
|
||||
prNumber: null,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('skips when cached null PR result is within the grace window', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 1_000,
|
||||
checksFetchedAt: undefined,
|
||||
commentsFetchedAt: undefined,
|
||||
prNumber: null,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes when PR is known but checks cache is missing (first entry after app start)', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 1_000,
|
||||
checksFetchedAt: undefined,
|
||||
commentsFetchedAt: NOW - 1_000,
|
||||
prNumber: 42,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes when PR is known but comments cache is missing', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 1_000,
|
||||
checksFetchedAt: NOW - 1_000,
|
||||
commentsFetchedAt: undefined,
|
||||
prNumber: 42,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes when checks timestamp is older than the grace window', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 1_000,
|
||||
checksFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1,
|
||||
commentsFetchedAt: NOW - 1_000,
|
||||
prNumber: 42,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes when comments timestamp is older than the grace window', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 1_000,
|
||||
checksFetchedAt: NOW - 1_000,
|
||||
commentsFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS - 1,
|
||||
prNumber: 42,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('skips when PR, checks, and comments are all fresh within the grace window', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 5_000,
|
||||
checksFetchedAt: NOW - 5_000,
|
||||
commentsFetchedAt: NOW - 5_000,
|
||||
prNumber: 42,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores checks/comments freshness when no PR is known', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 1_000,
|
||||
checksFetchedAt: undefined,
|
||||
commentsFetchedAt: undefined,
|
||||
prNumber: null,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a PR timestamp exactly at the cutoff as fresh', () => {
|
||||
// Why: the rule is "older than now - grace", strict less-than. Equal is fresh.
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS,
|
||||
checksFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS,
|
||||
commentsFetchedAt: NOW - ENTRY_REFRESH_GRACE_MS,
|
||||
prNumber: 42,
|
||||
now: NOW
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors a custom graceMs override', () => {
|
||||
expect(
|
||||
shouldEntryRefresh({
|
||||
prFetchedAt: NOW - 2_000,
|
||||
checksFetchedAt: NOW - 2_000,
|
||||
commentsFetchedAt: NOW - 2_000,
|
||||
prNumber: 42,
|
||||
now: NOW,
|
||||
graceMs: 1_000
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Why: extracted as a pure helper so the freshness decision is unit-testable
|
||||
// without mounting ChecksPanel and its many store dependencies. The rules come
|
||||
// from docs/refresh-on-checks-tab.md (Grace window + Edge cases sections).
|
||||
|
||||
export const ENTRY_REFRESH_GRACE_MS = 15_000
|
||||
|
||||
export type EntryRefreshInput = {
|
||||
prFetchedAt: number | undefined
|
||||
checksFetchedAt: number | undefined
|
||||
commentsFetchedAt: number | undefined
|
||||
prNumber: number | null
|
||||
now: number
|
||||
graceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether entering the Checks tab should trigger a force refresh.
|
||||
*
|
||||
* Rules:
|
||||
* - Missing PR cache timestamp is stale (cold start, never fetched).
|
||||
* - A cached PR timestamp older than `now - graceMs` is stale; this also
|
||||
* covers cached `null` PR results, which still have a fetchedAt.
|
||||
* - When a PR number is known, missing checks/comments timestamps are stale
|
||||
* (their caches are not persisted, so they restart empty).
|
||||
* - When no PR number is known, checks/comments timestamps are not relevant.
|
||||
*/
|
||||
export function shouldEntryRefresh(input: EntryRefreshInput): boolean {
|
||||
const { prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, now } = input
|
||||
const graceMs = input.graceMs ?? ENTRY_REFRESH_GRACE_MS
|
||||
const cutoff = now - graceMs
|
||||
|
||||
if (prFetchedAt === undefined || prFetchedAt < cutoff) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (prNumber !== null) {
|
||||
if (checksFetchedAt === undefined || checksFetchedAt < cutoff) {
|
||||
return true
|
||||
}
|
||||
if (commentsFetchedAt === undefined || commentsFetchedAt < cutoff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -31,8 +31,9 @@ export const NOTIFICATIONS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
},
|
||||
{
|
||||
title: 'Custom Sound',
|
||||
description: 'Choose one local audio file for all delivered desktop notifications.',
|
||||
keywords: ['notifications', 'sound', 'audio', 'ogg', 'mp3', 'wav']
|
||||
description:
|
||||
'Choose one local audio file (MP3, WAV, OGG, M4A, AAC, or FLAC) for all delivered desktop notifications.',
|
||||
keywords: ['notifications', 'sound', 'audio', 'mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac']
|
||||
},
|
||||
{
|
||||
title: 'Send Test Notification',
|
||||
@@ -141,6 +142,9 @@ export function NotificationsPane({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One local audio file for all delivered desktop notifications.
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
Supported formats: MP3, WAV, OGG, M4A, AAC, FLAC.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Activity, GitBranch, ListFilter, FolderPlus, X } from 'lucide-react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Activity, Check, GitBranch, ListFilter, FolderPlus, Server, X } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
||||
import { searchRepos } from '@/lib/repo-search'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SidebarFilter = React.memo(function SidebarFilter() {
|
||||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
@@ -24,6 +25,17 @@ const SidebarFilter = React.memo(function SidebarFilter() {
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const addRepo = useAppStore((s) => s.addRepo)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next)
|
||||
if (!next) {
|
||||
setQuery('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToggleRepo = useCallback(
|
||||
(repoId: string) => {
|
||||
setFilterRepoIds(
|
||||
@@ -35,29 +47,46 @@ const SidebarFilter = React.memo(function SidebarFilter() {
|
||||
[filterRepoIds, setFilterRepoIds]
|
||||
)
|
||||
|
||||
const handleToggleActive = useCallback(
|
||||
() => setShowActiveOnly(!showActiveOnly),
|
||||
[showActiveOnly, setShowActiveOnly]
|
||||
)
|
||||
const handleToggleHideDefaultBranch = useCallback(
|
||||
() => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace),
|
||||
[hideDefaultBranchWorkspace, setHideDefaultBranchWorkspace]
|
||||
)
|
||||
const canFilterRepos = repos.length > 1
|
||||
// Why: derive from the current repos list so stale IDs in filterRepoIds
|
||||
// (e.g. lingering after a repo is removed) don't inflate the active-filter
|
||||
// count or falsely signal an applied filter.
|
||||
const selectedRepos = canFilterRepos ? repos.filter((r) => filterRepoIds.includes(r.id)) : []
|
||||
const hasRepoFilter = selectedRepos.length > 0
|
||||
// Why: derive from current repos so stale ids (e.g. lingering after a repo
|
||||
// is removed) don't inflate counts or falsely signal an applied filter.
|
||||
const selectedRepoIdSet = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const r of repos) {
|
||||
if (filterRepoIds.includes(r.id)) {
|
||||
set.add(r.id)
|
||||
}
|
||||
}
|
||||
return set
|
||||
}, [repos, filterRepoIds])
|
||||
const selectedCount = selectedRepoIdSet.size
|
||||
const hasRepoFilter = selectedCount > 0
|
||||
const hasAnyFilter = showActiveOnly || hideDefaultBranchWorkspace || hasRepoFilter
|
||||
const activeFilterCount =
|
||||
(showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedRepos.length
|
||||
(showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
|
||||
|
||||
const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query])
|
||||
const allSelected = canFilterRepos && selectedCount === repos.length
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setShowActiveOnly(false)
|
||||
setHideDefaultBranchWorkspace(false)
|
||||
setFilterRepoIds([])
|
||||
}, [setShowActiveOnly, setHideDefaultBranchWorkspace, setFilterRepoIds])
|
||||
|
||||
// Why: derive ids from the live repos list at click time so a repo added
|
||||
// while the popover is open is included immediately.
|
||||
const selectAllRepos = useCallback(() => {
|
||||
setFilterRepoIds(repos.map((r) => r.id))
|
||||
}, [repos, setFilterRepoIds])
|
||||
|
||||
const clearRepos = useCallback(() => setFilterRepoIds([]), [setFilterRepoIds])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
@@ -79,77 +108,176 @@ const SidebarFilter = React.memo(function SidebarFilter() {
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{hasAnyFilter ? 'Edit filters' : 'Filter workspaces'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showActiveOnly}
|
||||
onCheckedChange={handleToggleActive}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
Active only
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hideDefaultBranchWorkspace}
|
||||
onCheckedChange={handleToggleHideDefaultBranch}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<GitBranch className="size-3.5 text-muted-foreground" />
|
||||
Hide default branch
|
||||
</DropdownMenuCheckboxItem>
|
||||
{canFilterRepos && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Repositories</DropdownMenuLabel>
|
||||
{repos.map((r) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={r.id}
|
||||
checked={filterRepoIds.includes(r.id)}
|
||||
onCheckedChange={() => handleToggleRepo(r.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{hasAnyFilter && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setShowActiveOnly(false)
|
||||
setHideDefaultBranchWorkspace(false)
|
||||
setFilterRepoIds([])
|
||||
}}
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs font-medium text-foreground">Filters</span>
|
||||
{hasAnyFilter ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5 text-muted-foreground" />
|
||||
Clear filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<X className="size-3" />
|
||||
Clear all
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60">
|
||||
<ToggleRow
|
||||
icon={<Activity className="size-3.5" />}
|
||||
label="Active only"
|
||||
checked={showActiveOnly}
|
||||
onClick={() => setShowActiveOnly(!showActiveOnly)}
|
||||
/>
|
||||
<ToggleRow
|
||||
icon={<GitBranch className="size-3.5" />}
|
||||
label="Hide default branch"
|
||||
checked={hideDefaultBranchWorkspace}
|
||||
onClick={() => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canFilterRepos && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
inset
|
||||
onSelect={() => {
|
||||
addRepo()
|
||||
}}
|
||||
<div className="border-t border-border/60">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Repositories
|
||||
{hasRepoFilter && (
|
||||
<span className="ml-1.5 text-foreground normal-case tracking-normal">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAllRepos}
|
||||
className="hover:text-foreground disabled:opacity-40"
|
||||
disabled={allSelected}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRepos}
|
||||
className="hover:text-foreground disabled:opacity-40"
|
||||
disabled={!hasRepoFilter}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
value={commandValue}
|
||||
onValueChange={setCommandValue}
|
||||
className="bg-transparent"
|
||||
>
|
||||
<FolderPlus className="absolute left-2.5 size-3.5 text-muted-foreground" />
|
||||
Add project
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder="Search repos..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="h-8 py-2 text-xs"
|
||||
wrapperClassName="px-3"
|
||||
iconClassName="h-3.5 w-3.5"
|
||||
/>
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty className="py-4 text-[11px]">No repos match</CommandEmpty>
|
||||
{filteredRepos.map((r) => {
|
||||
const checked = selectedRepoIdSet.has(r.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={r.id}
|
||||
value={r.id}
|
||||
onSelect={() => handleToggleRepo(r.id)}
|
||||
className="items-center gap-2 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-muted-foreground',
|
||||
checked ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<RepoDotLabel
|
||||
name={r.displayName}
|
||||
color={r.badgeColor}
|
||||
className="max-w-full"
|
||||
/>
|
||||
{r.connectionId && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
|
||||
<Server className="size-2.5" />
|
||||
SSH
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Why: per design, "Add project" stays visible regardless of repo
|
||||
count so users can recover from the 0/1-repo state where the
|
||||
repo section is hidden. */}
|
||||
<div className="border-t border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addRepo()}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
Add project
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
type ToggleRowProps = {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
checked: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ToggleRow({ icon, label, checked, onClick }: ToggleRowProps) {
|
||||
// Why: the popover is not a true menu, so we use a plain button with
|
||||
// aria-pressed rather than role="menuitemcheckbox". The visible checkmark
|
||||
// carries the state for sighted users.
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={checked}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
checked ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-muted-foreground',
|
||||
checked ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className={cn(checked && 'text-foreground')}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SidebarFilter
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
sidebarHasActiveFilters
|
||||
} from './visible-worktrees'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useRepoHeaderDrag } from './repo-header-drag'
|
||||
|
||||
// How long to wait after a sortEpoch bump before actually re-sorting.
|
||||
// Prevents jarring position shifts when background events (AI starting work,
|
||||
@@ -79,6 +80,13 @@ type VirtualizedWorktreeViewportProps = {
|
||||
clearPendingRevealWorktreeId: () => void
|
||||
worktrees: Worktree[]
|
||||
repoMap: Map<string, Repo>
|
||||
repoOrder: Map<string, number>
|
||||
// The full canonical state.repos id ordering — the drag controller commits
|
||||
// permutations of this list, even when some repos aren't currently visible
|
||||
// (filtered out / collapsed-only). Visible-only ids would silently drop the
|
||||
// hidden repos on reorder.
|
||||
allRepoIds: string[]
|
||||
reorderRepos: (orderedIds: string[]) => void
|
||||
prCache: Record<string, unknown> | null
|
||||
// Why: the viewport remounts when the row structure changes (see
|
||||
// viewportResetKey) so the virtualizer's measurementsCache cannot hold
|
||||
@@ -101,10 +109,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
clearPendingRevealWorktreeId,
|
||||
worktrees,
|
||||
repoMap,
|
||||
repoOrder,
|
||||
allRepoIds,
|
||||
reorderRepos,
|
||||
prCache,
|
||||
scrollOffsetRef
|
||||
}: VirtualizedWorktreeViewportProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Drag is only meaningful when the user is grouping by repo. When inert
|
||||
// (groupBy !== 'repo'), the controller is still constructed for hook order
|
||||
// stability but the handle is never rendered.
|
||||
const repoDrag = useRepoHeaderDrag({
|
||||
orderedRepoIds: allRepoIds,
|
||||
onCommit: reorderRepos,
|
||||
getScrollContainer: () => scrollRef.current
|
||||
})
|
||||
const activeWorktreeRowIndex = useMemo(
|
||||
() => rows.findIndex((row) => row.type === 'item' && row.worktree.id === activeWorktreeId),
|
||||
[rows, activeWorktreeId]
|
||||
@@ -260,7 +280,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
new Set<string>()
|
||||
new Set<string>(),
|
||||
repoOrder
|
||||
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
|
||||
if (worktreeRows.length === 0) {
|
||||
return
|
||||
@@ -293,7 +314,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
virtualizer.scrollToIndex(rowIndex, { align: 'auto' })
|
||||
}
|
||||
},
|
||||
[rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache]
|
||||
[rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache, repoOrder]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -368,10 +389,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
className="relative w-full"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{repoDrag.state.draggingRepoId !== null && repoDrag.state.dropIndicatorY !== null ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="pointer-events-none absolute left-2 right-2 z-10 border-t border-dashed border-muted-foreground/70"
|
||||
style={{ top: `${repoDrag.state.dropIndicatorY}px` }}
|
||||
/>
|
||||
) : null}
|
||||
{virtualItems.map((vItem) => {
|
||||
const row = rows[vItem.index]
|
||||
|
||||
if (row.type === 'header') {
|
||||
const isRepoHeader = groupBy === 'repo' && row.repo !== undefined
|
||||
const repoIdForHeader = isRepoHeader ? row.repo!.id : undefined
|
||||
const isDraggingThis =
|
||||
repoDrag.state.draggingRepoId !== null &&
|
||||
repoDrag.state.draggingRepoId === repoIdForHeader
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
@@ -384,8 +417,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-repo-header-id={repoIdForHeader}
|
||||
className={cn(
|
||||
'group flex h-7 w-full items-center gap-1.5 pl-3 pr-1 text-left transition-all cursor-pointer',
|
||||
'group flex h-7 w-full items-center gap-1.5 pl-3 pr-1 text-left transition-all',
|
||||
'cursor-pointer',
|
||||
isDraggingThis &&
|
||||
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
|
||||
// First header sits directly under SidebarHeader, which already
|
||||
// supplies its own spacing — only offset secondary group headers.
|
||||
vItem.index !== firstHeaderIndex && 'mt-2',
|
||||
@@ -401,6 +438,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
>
|
||||
{row.icon ? (
|
||||
<div
|
||||
onPointerDown={
|
||||
isRepoHeader && repoIdForHeader
|
||||
? (e) => repoDrag.onHandlePointerDown(e, repoIdForHeader)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
|
||||
row.repo && 'text-muted-foreground'
|
||||
@@ -711,10 +753,22 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
const collapsedGroups = useAppStore((s) => s.collapsedGroups)
|
||||
const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup)
|
||||
|
||||
// Why: header order in groupBy='repo' is bound to state.repos array order so
|
||||
// manual reorder is the single source of truth. The Map lets buildRows do an
|
||||
// O(1) rank lookup per group without depending on Repo identity.
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const repoOrder = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
repos.forEach((r, i) => map.set(r.id, i))
|
||||
return map
|
||||
}, [repos])
|
||||
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
|
||||
const reorderReposAction = useAppStore((s) => s.reorderRepos)
|
||||
|
||||
// Build flat row list for rendering
|
||||
const rows: Row[] = useMemo(
|
||||
() => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups),
|
||||
[groupBy, worktrees, repoMap, prCache, collapsedGroups]
|
||||
() => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder),
|
||||
[groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder]
|
||||
)
|
||||
// Why: rows.length alone can stay the same when items migrate between
|
||||
// groups (e.g., PR cache loads on restart and a collapsed group absorbs
|
||||
@@ -824,6 +878,11 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
|
||||
worktrees={worktrees}
|
||||
repoMap={repoMap}
|
||||
repoOrder={repoOrder}
|
||||
allRepoIds={allRepoIds}
|
||||
reorderRepos={(orderedIds) => {
|
||||
void reorderReposAction(orderedIds)
|
||||
}}
|
||||
prCache={prCache}
|
||||
scrollOffsetRef={sidebarScrollOffsetRef}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Why pointer events instead of HTML5 DnD: rows are absolutely-positioned by
|
||||
// react-virtual and unmount/remount as scroll changes, so DnD enter/leave fire
|
||||
// against stale targets. With pointer events we cache the active set of repo
|
||||
// header positions and compute the drop index from the live pointer Y.
|
||||
|
||||
export type RepoDragState = {
|
||||
draggingRepoId: string | null
|
||||
// Insertion index in the orderedRepoIds array where the dragged repo would
|
||||
// land if released now. null while not dragging.
|
||||
dropIndex: number | null
|
||||
// Y coordinate (in scrollContainer's local space, i.e. relative to its
|
||||
// top-left content origin including current scrollTop offset) where the
|
||||
// insertion bar should be drawn. null while not dragging.
|
||||
dropIndicatorY: number | null
|
||||
}
|
||||
|
||||
const INITIAL_STATE: RepoDragState = {
|
||||
draggingRepoId: null,
|
||||
dropIndex: null,
|
||||
dropIndicatorY: null
|
||||
}
|
||||
|
||||
export type UseRepoHeaderDragArgs = {
|
||||
orderedRepoIds: string[]
|
||||
onCommit: (orderedIds: string[]) => void
|
||||
// Returns the scroll container that hosts the virtualized rows. Bounding
|
||||
// rects are read from this element so insertion-bar Y values stay correct
|
||||
// when the sidebar is resized.
|
||||
getScrollContainer: () => HTMLElement | null
|
||||
}
|
||||
|
||||
type HeaderRect = {
|
||||
repoId: string
|
||||
// top/bottom in scrollContainer-local space (page-coord top minus container
|
||||
// page-coord top, plus current scrollTop).
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export type RepoHeaderDragController = {
|
||||
state: RepoDragState
|
||||
// Call from the repo header's onPointerDown. The drag does NOT start
|
||||
// immediately — it arms a pending session that promotes to an active drag
|
||||
// only once the pointer moves past DRAG_THRESHOLD_PX. A pointerup before
|
||||
// promotion releases without committing, so the surrounding click handler
|
||||
// still fires and toggles the group's collapsed state.
|
||||
onHandlePointerDown: (event: React.PointerEvent<HTMLElement>, repoId: string) => void
|
||||
}
|
||||
|
||||
// Pixels the pointer must travel before we promote a pending press into a
|
||||
// real drag. Below this we treat the press as a normal click (toggle group).
|
||||
const DRAG_THRESHOLD_PX = 4
|
||||
|
||||
export function useRepoHeaderDrag({
|
||||
orderedRepoIds,
|
||||
onCommit,
|
||||
getScrollContainer
|
||||
}: UseRepoHeaderDragArgs): RepoHeaderDragController {
|
||||
const [state, setState] = useState<RepoDragState>(INITIAL_STATE)
|
||||
// Tracks whether a press has begun (armed) regardless of promotion. Used
|
||||
// only to gate window listeners; visible drag state lives in `state`.
|
||||
const [sessionArmed, setSessionArmed] = useState(false)
|
||||
// Why: endDrag reads dropIndex on pointerup, but binding the listener with
|
||||
// dropIndex in deps would re-add window listeners on every pointermove.
|
||||
// The ref tracks the latest computed value without invalidating the effect.
|
||||
const latestDropIndexRef = useRef<number | null>(null)
|
||||
latestDropIndexRef.current = state.dropIndex
|
||||
// Keep callbacks stable: they read from refs so we don't re-bind window
|
||||
// listeners every render.
|
||||
const orderedIdsRef = useRef(orderedRepoIds)
|
||||
orderedIdsRef.current = orderedRepoIds
|
||||
const onCommitRef = useRef(onCommit)
|
||||
onCommitRef.current = onCommit
|
||||
const getContainerRef = useRef(getScrollContainer)
|
||||
getContainerRef.current = getScrollContainer
|
||||
|
||||
const dragSessionRef = useRef<{
|
||||
repoId: string
|
||||
pointerId: number
|
||||
headerRects: HeaderRect[]
|
||||
handleEl: HTMLElement
|
||||
startX: number
|
||||
startY: number
|
||||
// false until the pointer moves past DRAG_THRESHOLD_PX. While false the
|
||||
// session exists but no drop indicator is shown and pointerup is treated
|
||||
// as a click rather than a drop.
|
||||
promoted: boolean
|
||||
} | null>(null)
|
||||
|
||||
const computeDrop = useCallback(
|
||||
(pointerY: number): { dropIndex: number; dropIndicatorY: number } | null => {
|
||||
const session = dragSessionRef.current
|
||||
const container = getContainerRef.current()
|
||||
if (!session || !container) {
|
||||
return null
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
// Translate pointer to container-local coords + scroll.
|
||||
const localY = pointerY - containerRect.top + container.scrollTop
|
||||
const rects = session.headerRects
|
||||
if (rects.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Find the first header whose midpoint is below the pointer.
|
||||
let insertBefore = rects.length
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const mid = (rects[i].top + rects[i].bottom) / 2
|
||||
if (localY < mid) {
|
||||
insertBefore = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// Why anchor to the target header (not midpoint between headers): the
|
||||
// space between two repo group headers is filled with worktree cards,
|
||||
// so the midpoint falls *inside another repo's content*. Sitting the
|
||||
// indicator just above the target header keeps it at the visual top of
|
||||
// where the dragged group would land.
|
||||
const INDICATOR_GAP_PX = 4
|
||||
const indicatorY =
|
||||
insertBefore >= rects.length
|
||||
? rects.at(-1)!.bottom + INDICATOR_GAP_PX
|
||||
: Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX)
|
||||
return { dropIndex: insertBefore, dropIndicatorY: indicatorY }
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const endDrag = useCallback((commit: boolean) => {
|
||||
const session = dragSessionRef.current
|
||||
if (!session) {
|
||||
setState(INITIAL_STATE)
|
||||
setSessionArmed(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
session.handleEl.releasePointerCapture(session.pointerId)
|
||||
} catch {
|
||||
// capture may already be released (pointercancel, element unmounted)
|
||||
}
|
||||
if (session.promoted) {
|
||||
// After a real drag, the browser still fires a click on the header.
|
||||
// Swallow exactly one click in capture phase so it doesn't toggle the
|
||||
// group's collapsed state. Scope to the dragged handle (and ancestors)
|
||||
// so an unrelated click that races between pointerup and the failsafe
|
||||
// teardown isn't silently eaten.
|
||||
const handleEl = session.handleEl
|
||||
const swallow = (e: MouseEvent): void => {
|
||||
const target = e.target as Node | null
|
||||
if (target && handleEl.contains(target)) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
window.removeEventListener('click', swallow, true)
|
||||
}
|
||||
window.addEventListener('click', swallow, true)
|
||||
// Failsafe: if no click ever arrives (e.g. pointercancel), drop the
|
||||
// listener after a tick so future clicks aren't silenced.
|
||||
setTimeout(() => window.removeEventListener('click', swallow, true), 0)
|
||||
}
|
||||
// Only commit a reorder if the press was promoted into a real drag —
|
||||
// otherwise the press was effectively a click, and the surrounding
|
||||
// header onClick handler will toggle collapse.
|
||||
const finalIndex =
|
||||
commit && session.promoted && latestDropIndexRef.current !== null
|
||||
? latestDropIndexRef.current
|
||||
: null
|
||||
dragSessionRef.current = null
|
||||
setState(INITIAL_STATE)
|
||||
setSessionArmed(false)
|
||||
if (finalIndex === null) {
|
||||
return
|
||||
}
|
||||
const ids = orderedIdsRef.current
|
||||
const fromIndex = ids.indexOf(session.repoId)
|
||||
if (fromIndex === -1) {
|
||||
return
|
||||
}
|
||||
// Splice fromIndex out, then insert at finalIndex (adjusting if the
|
||||
// removal shifted indices).
|
||||
const next = ids.slice()
|
||||
next.splice(fromIndex, 1)
|
||||
const insertAt = finalIndex > fromIndex ? finalIndex - 1 : finalIndex
|
||||
if (insertAt === fromIndex) {
|
||||
return
|
||||
}
|
||||
next.splice(insertAt, 0, session.repoId)
|
||||
onCommitRef.current(next)
|
||||
}, [])
|
||||
|
||||
// Window-level listeners while a session is armed — pointer capture on the
|
||||
// header element ensures the events still fire even if the pointer leaves
|
||||
// it. The session may be unpromoted (waiting for a movement past the
|
||||
// threshold to become a real drag) or promoted (drop indicator visible).
|
||||
useEffect(() => {
|
||||
if (!sessionArmed) {
|
||||
return
|
||||
}
|
||||
const onPointerMove = (e: PointerEvent): void => {
|
||||
const session = dragSessionRef.current
|
||||
if (!session || e.pointerId !== session.pointerId) {
|
||||
return
|
||||
}
|
||||
if (!session.promoted) {
|
||||
const dx = e.clientX - session.startX
|
||||
const dy = e.clientY - session.startY
|
||||
if (dx * dx + dy * dy < DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) {
|
||||
return
|
||||
}
|
||||
session.promoted = true
|
||||
setState({ draggingRepoId: session.repoId, dropIndex: null, dropIndicatorY: null })
|
||||
}
|
||||
const drop = computeDrop(e.clientY)
|
||||
if (!drop) {
|
||||
return
|
||||
}
|
||||
setState((prev) =>
|
||||
prev.dropIndex === drop.dropIndex && prev.dropIndicatorY === drop.dropIndicatorY
|
||||
? prev
|
||||
: { draggingRepoId: session.repoId, ...drop }
|
||||
)
|
||||
}
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
const session = dragSessionRef.current
|
||||
if (!session || e.pointerId !== session.pointerId) {
|
||||
return
|
||||
}
|
||||
endDrag(true)
|
||||
}
|
||||
const onPointerCancel = (e: PointerEvent): void => {
|
||||
const session = dragSessionRef.current
|
||||
if (!session || e.pointerId !== session.pointerId) {
|
||||
return
|
||||
}
|
||||
endDrag(false)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
endDrag(false)
|
||||
}
|
||||
}
|
||||
const onBlur = (): void => endDrag(false)
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [sessionArmed, computeDrop, endDrag])
|
||||
|
||||
// From pointerdown onward (armed session, before/after promotion) force a
|
||||
// grabbing cursor and disable text selection across the whole window so
|
||||
// the user gets immediate feedback that the press registered, even before
|
||||
// they've moved past DRAG_THRESHOLD_PX.
|
||||
useEffect(() => {
|
||||
if (!sessionArmed) {
|
||||
return
|
||||
}
|
||||
const body = document.body
|
||||
const prevCursor = body.style.cursor
|
||||
const prevUserSelect = body.style.userSelect
|
||||
body.style.cursor = 'grabbing'
|
||||
body.style.userSelect = 'none'
|
||||
return () => {
|
||||
body.style.cursor = prevCursor
|
||||
body.style.userSelect = prevUserSelect
|
||||
}
|
||||
}, [sessionArmed])
|
||||
|
||||
const onHandlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>, repoId: string) => {
|
||||
// Only react to primary button. Ignore right/middle clicks.
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
// Don't intercept presses that originated on a nested control (the
|
||||
// `+` create-worktree button or the chevron). Those have their own
|
||||
// click semantics and shouldn't arm a drag.
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target && target !== event.currentTarget && target.closest('button')) {
|
||||
return
|
||||
}
|
||||
const container = getContainerRef.current()
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
// Snapshot every repo header's position in scrollContainer-local space.
|
||||
// Using a snapshot (vs reading the DOM each pointermove) means the drop
|
||||
// computation does not depend on those rows still being mounted —
|
||||
// critical because react-virtual will unmount them as the user scrolls.
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const headerEls = container.querySelectorAll<HTMLElement>('[data-repo-header-id]')
|
||||
const headerRects: HeaderRect[] = []
|
||||
headerEls.forEach((el) => {
|
||||
const id = el.getAttribute('data-repo-header-id')
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const rect = el.getBoundingClientRect()
|
||||
headerRects.push({
|
||||
repoId: id,
|
||||
top: rect.top - containerRect.top + container.scrollTop,
|
||||
bottom: rect.bottom - containerRect.top + container.scrollTop
|
||||
})
|
||||
})
|
||||
headerRects.sort((a, b) => a.top - b.top)
|
||||
|
||||
const handleEl = event.currentTarget
|
||||
try {
|
||||
handleEl.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// setPointerCapture can throw if the element is detached; the global
|
||||
// pointer listeners still fire, so dragging keeps working.
|
||||
}
|
||||
dragSessionRef.current = {
|
||||
repoId,
|
||||
pointerId: event.pointerId,
|
||||
headerRects,
|
||||
handleEl,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
promoted: false
|
||||
}
|
||||
// Don't show drag UI yet. Wait for movement past DRAG_THRESHOLD_PX so a
|
||||
// simple click on the header still toggles collapse via the surrounding
|
||||
// onClick handler.
|
||||
setSessionArmed(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return { state, onHandlePointerDown }
|
||||
}
|
||||
@@ -137,6 +137,40 @@ describe('buildRows with pinned worktrees', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRows repo grouping order', () => {
|
||||
const repoA: Repo = { ...repo, id: 'repo-a', displayName: 'alpha' }
|
||||
const repoB: Repo = { ...repo, id: 'repo-b', displayName: 'beta' }
|
||||
const repoC: Repo = { ...repo, id: 'repo-c', displayName: 'gamma' }
|
||||
const map = new Map([
|
||||
[repoA.id, repoA],
|
||||
[repoB.id, repoB],
|
||||
[repoC.id, repoC]
|
||||
])
|
||||
const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' }
|
||||
const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' }
|
||||
const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' }
|
||||
|
||||
it('orders repo headers by explicit repoOrder, not first-encounter', () => {
|
||||
// Worktree stream encounters in order C, A, B — but repoOrder says B, A, C.
|
||||
const repoOrder = new Map([
|
||||
[repoB.id, 0],
|
||||
[repoA.id, 1],
|
||||
[repoC.id, 2]
|
||||
])
|
||||
const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder)
|
||||
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
|
||||
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
|
||||
})
|
||||
|
||||
it('places unknown repo ids last and sorts them by label', () => {
|
||||
// Only repoB is in repoOrder; repoA and repoC fall through to label sort.
|
||||
const repoOrder = new Map([[repoB.id, 0]])
|
||||
const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder)
|
||||
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
|
||||
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeList header styles', () => {
|
||||
it('does not title-case workspace group labels', () => {
|
||||
const source = readFileSync(
|
||||
|
||||
@@ -150,7 +150,8 @@ export function buildRows(
|
||||
worktrees: Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, unknown> | null,
|
||||
collapsedGroups: Set<string>
|
||||
collapsedGroups: Set<string>,
|
||||
repoOrder?: Map<string, number>
|
||||
): Row[] {
|
||||
const result: Row[] = []
|
||||
|
||||
@@ -210,7 +211,28 @@ export function buildRows(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
orderedGroups.push(...Array.from(grouped.entries()))
|
||||
// Why: header order must follow the canonical state.repos array order, not
|
||||
// first-encounter from the smart-sorted worktree stream — otherwise sorting
|
||||
// or filtering side effects could shuffle which repo header appears first,
|
||||
// and manual reorder would have nothing to bind to. Unknown ids (no entry
|
||||
// in repoOrder) sort last by label so they remain deterministic.
|
||||
const entries = Array.from(grouped.entries())
|
||||
if (repoOrder) {
|
||||
const rankFor = (key: string): number => {
|
||||
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
|
||||
const rank = repoOrder.get(repoId)
|
||||
return rank === undefined ? Number.POSITIVE_INFINITY : rank
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
const ra = rankFor(a[0])
|
||||
const rb = rankFor(b[0])
|
||||
if (ra !== rb) {
|
||||
return ra - rb
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
}
|
||||
orderedGroups.push(...entries)
|
||||
}
|
||||
|
||||
for (const [key, group] of orderedGroups) {
|
||||
|
||||
@@ -30,6 +30,7 @@ export type RepoSlice = {
|
||||
>
|
||||
) => Promise<void>
|
||||
setActiveRepo: (repoId: string | null) => void
|
||||
reorderRepos: (orderedIds: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set, get) => ({
|
||||
@@ -267,5 +268,33 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
setActiveRepo: (repoId) => set({ activeRepoId: repoId })
|
||||
setActiveRepo: (repoId) => set({ activeRepoId: repoId }),
|
||||
|
||||
reorderRepos: async (orderedIds) => {
|
||||
// Optimistically apply the new order so the sidebar updates instantly;
|
||||
// resync only if main rejects (stale permutation due to a racing add/remove).
|
||||
const previous = get().repos
|
||||
const byId = new Map(previous.map((r) => [r.id, r]))
|
||||
const next: Repo[] = []
|
||||
for (const id of orderedIds) {
|
||||
const repo = byId.get(id)
|
||||
if (repo) {
|
||||
next.push(repo)
|
||||
}
|
||||
}
|
||||
if (next.length !== previous.length) {
|
||||
// Caller passed a non-permutation — refuse to apply locally.
|
||||
return
|
||||
}
|
||||
set({ repos: next })
|
||||
try {
|
||||
const result = await window.api.repos.reorder({ orderedIds })
|
||||
if (result.status === 'rejected') {
|
||||
await get().fetchRepos()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reorder repos:', err)
|
||||
await get().fetchRepos()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
createHookListenerState,
|
||||
getEndpointFileName,
|
||||
isShellSafeEndpointValue,
|
||||
normalizeHookPayload,
|
||||
parseFormEncodedBody,
|
||||
resolveHookSource,
|
||||
writeEndpointFile,
|
||||
type HookListenerState
|
||||
} from './agent-hook-listener'
|
||||
|
||||
describe('shared agent-hook-listener', () => {
|
||||
let state: HookListenerState
|
||||
|
||||
beforeEach(() => {
|
||||
state = createHookListenerState()
|
||||
})
|
||||
|
||||
it('parses form-encoded bodies', () => {
|
||||
const decoded = parseFormEncodedBody('paneKey=tab-1%3A0&worktreeId=foo')
|
||||
expect(decoded.paneKey).toBe('tab-1:0')
|
||||
expect(decoded.worktreeId).toBe('foo')
|
||||
})
|
||||
|
||||
it('routes pathnames to a known source or null', () => {
|
||||
expect(resolveHookSource('/hook/claude')).toBe('claude')
|
||||
expect(resolveHookSource('/hook/cursor')).toBe('cursor')
|
||||
expect(resolveHookSource('/hook/unknown')).toBeNull()
|
||||
expect(resolveHookSource('/')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects shell-unsafe endpoint values', () => {
|
||||
expect(isShellSafeEndpointValue('1234')).toBe(true)
|
||||
expect(isShellSafeEndpointValue('abc-DEF.0_1')).toBe(true)
|
||||
expect(isShellSafeEndpointValue('')).toBe(false)
|
||||
expect(isShellSafeEndpointValue('foo&bar')).toBe(false)
|
||||
expect(isShellSafeEndpointValue('foo bar')).toBe(false)
|
||||
expect(isShellSafeEndpointValue('foo;bar')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes a Claude UserPromptSubmit body to a working state', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt',
|
||||
env: 'production',
|
||||
version: '1',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hello' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.paneKey).toBe('tab-1:0')
|
||||
expect(event!.connectionId).toBeNull()
|
||||
expect(event!.payload.state).toBe('working')
|
||||
expect(event!.payload.prompt).toBe('hello')
|
||||
expect(event!.payload.agentType).toBe('claude')
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace from extracted prompt text', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'tab-1:0',
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: ' hi ' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.payload.prompt).toBe('hi')
|
||||
})
|
||||
|
||||
it('rejects oversized paneKey', () => {
|
||||
const event = normalizeHookPayload(
|
||||
state,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'x'.repeat(300),
|
||||
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).toBeNull()
|
||||
})
|
||||
|
||||
it('isolates caches between listener instances', () => {
|
||||
const a = createHookListenerState()
|
||||
const b = createHookListenerState()
|
||||
normalizeHookPayload(
|
||||
a,
|
||||
'claude',
|
||||
{ paneKey: 'p', payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } },
|
||||
'production'
|
||||
)
|
||||
// The second listener has no cached prompt for this paneKey, so a tool
|
||||
// event without a fresh prompt should produce empty prompt string.
|
||||
const event = normalizeHookPayload(
|
||||
b,
|
||||
'claude',
|
||||
{
|
||||
paneKey: 'p',
|
||||
payload: {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/etc/hosts' }
|
||||
}
|
||||
},
|
||||
'production'
|
||||
)
|
||||
expect(event).not.toBeNull()
|
||||
expect(event!.payload.prompt).toBe('')
|
||||
})
|
||||
|
||||
describe('writeEndpointFile', () => {
|
||||
let dir: string
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'agent-hook-listener-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes the endpoint file atomically with the right contents and mode', () => {
|
||||
const finalPath = join(dir, getEndpointFileName())
|
||||
const ok = writeEndpointFile(dir, finalPath, {
|
||||
port: 12345,
|
||||
token: 'abcdef-0123',
|
||||
env: 'production',
|
||||
version: '1'
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
const text = readFileSync(finalPath, 'utf8')
|
||||
expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345')
|
||||
expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123')
|
||||
expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1')
|
||||
// POSIX 0o600 — owner read/write only.
|
||||
if (process.platform !== 'win32') {
|
||||
const mode = statSync(finalPath).mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses unsafe values', () => {
|
||||
const finalPath = join(dir, getEndpointFileName())
|
||||
const ok = writeEndpointFile(dir, finalPath, {
|
||||
port: 12345,
|
||||
token: 'safe-token',
|
||||
env: 'foo&bar',
|
||||
version: '1'
|
||||
})
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
|
||||
AGENT_HOOK_NOTIFICATION_METHOD,
|
||||
AGENT_HOOK_REQUEST_REPLAY_METHOD,
|
||||
ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV,
|
||||
isRemoteAgentHooksEnabled,
|
||||
type AgentHookRelayEnvelope
|
||||
} from './agent-hook-relay'
|
||||
|
||||
describe('agent-hook-relay wire shape', () => {
|
||||
it('encodes/decodes through JSON without losing fields', () => {
|
||||
const envelope: AgentHookRelayEnvelope = {
|
||||
source: 'claude',
|
||||
paneKey: 'tab-1:0',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
env: 'production',
|
||||
version: '1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'roundtrip',
|
||||
agentType: 'claude'
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = JSON.parse(JSON.stringify(envelope)) as AgentHookRelayEnvelope
|
||||
expect(decoded).toEqual(envelope)
|
||||
expect(decoded.connectionId).toBeNull()
|
||||
expect(decoded.payload.prompt).toBe('roundtrip')
|
||||
})
|
||||
|
||||
it('exposes stable JSON-RPC method names', () => {
|
||||
expect(AGENT_HOOK_NOTIFICATION_METHOD).toBe('agent.hook')
|
||||
expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay')
|
||||
expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRemoteAgentHooksEnabled', () => {
|
||||
it('is off when the env var is absent', () => {
|
||||
expect(isRemoteAgentHooksEnabled({})).toBe(false)
|
||||
})
|
||||
|
||||
it('is off for empty / "0"', () => {
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '' })).toBe(false)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '0' })).toBe(false)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: ' ' })).toBe(false)
|
||||
})
|
||||
|
||||
it('is on for any other non-empty value', () => {
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '1' })).toBe(true)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'on' })).toBe(true)
|
||||
expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'true' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification
|
||||
// the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which
|
||||
// produces it after the shared listener parses an HTTP POST) and by
|
||||
// `src/main/agent-hooks/server.ts` (which ingests it via `ingestRemote`).
|
||||
//
|
||||
// Lives in `shared/` because the relay deliberately has no Electron dependency
|
||||
// (cf. `src/relay/protocol.ts` header). `agent-hook-types.ts` is reserved for
|
||||
// the renderer-bound IPC + installer contract; this module is the wire envelope
|
||||
// between Orca's main process and the remote relay.
|
||||
//
|
||||
// Per the design doc:
|
||||
// - The relay normalizes; Orca routes. The envelope's `payload` field has
|
||||
// already been through `normalizeHookPayload` on the relay side; Orca's
|
||||
// ingestRemote re-runs the canonical normalizer at the trust boundary
|
||||
// (defense-in-depth) before feeding the event into the same `onAgentStatus`
|
||||
// fanout the local HTTP path uses.
|
||||
// - The wire `connectionId` is **always `null`**: a `connectionId` is Orca's
|
||||
// local handle on an `ssh2` connection, not a wire identity. Orca stamps the
|
||||
// real value on receive from `mux` identity inside `ingestRemote`.
|
||||
// - The wire `version` and `env` fields are forwarded verbatim from the agent
|
||||
// CLI's POST body so Orca's existing warn-once cross-build / dev-vs-prod
|
||||
// diagnostics still fire on remote-sourced events.
|
||||
|
||||
import type { ParsedAgentStatusPayload } from './agent-status-types'
|
||||
|
||||
// Why: the local hook server knows the discriminator from URL pathname routing
|
||||
// (`/hook/<source>`); the relay equally must tag each forwarded notification
|
||||
// with the same value so Orca can attribute the event back to the right CLI.
|
||||
// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it
|
||||
// without dragging Electron in (the shared listener module is the only place
|
||||
// that consumes it from the relay side).
|
||||
export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi'
|
||||
|
||||
/** Wire envelope for a single hook event flowing relay → Orca. */
|
||||
export type AgentHookRelayEnvelope = {
|
||||
source: AgentHookSource
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
/** Always `null` on the wire — relay does not know Orca's local connectionId. */
|
||||
connectionId: null
|
||||
/** Forwarded verbatim from the agent CLI POST body (e.g. 'production',
|
||||
* 'development'). Lets Orca's warn-once env-mismatch diagnostic fire on
|
||||
* remote events the same as on local. */
|
||||
env?: string
|
||||
/** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once
|
||||
* protocol-version diagnostic fire on remote events the same as on local. */
|
||||
version?: string
|
||||
/** Pre-normalized status payload from the relay's `normalizeHookPayload`.
|
||||
* Orca's `ingestRemote` re-validates via `normalizeAgentStatusPayload` at
|
||||
* the trust boundary as defense-in-depth. */
|
||||
payload: ParsedAgentStatusPayload
|
||||
}
|
||||
|
||||
/** JSON-RPC notification method name carried over the relay control channel. */
|
||||
export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const
|
||||
|
||||
/** JSON-RPC request method Orca issues after `--connect` reattach to ask the
|
||||
* relay to replay its per-paneKey last-payload cache. See §5 Path 3 of the
|
||||
* design doc for the race that ruled out push-on-`setWrite`. */
|
||||
export const AGENT_HOOK_REQUEST_REPLAY_METHOD = 'agent_hook.requestReplay' as const
|
||||
|
||||
/** JSON-RPC request method Orca issues at session-ready to ship the
|
||||
* OpenCode/Pi plugin source files to the relay so it can materialize the
|
||||
* per-PTY overlay dirs on the remote. */
|
||||
export const AGENT_HOOK_INSTALL_PLUGINS_METHOD = 'agent_hook.installPlugins' as const
|
||||
|
||||
/** Feature-flag env var. Read once at process start by Orca and the relay.
|
||||
* Absent / empty / "0" = off; anything else = on. See §8 of the design doc
|
||||
* for the gate locations. */
|
||||
export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const
|
||||
|
||||
export function isRemoteAgentHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const raw = env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]
|
||||
if (raw === undefined) {
|
||||
return false
|
||||
}
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed.length === 0 || trimmed === '0') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -114,6 +114,11 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
|
||||
paneKey: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
/** Identifies the SSH connection the event arrived on, or null for local.
|
||||
* Stamped only on the remote-ingest path (Orca's `ingestRemote`); the
|
||||
* HTTP path always sets null because it cannot know which mux a request
|
||||
* came from. See docs/design/agent-status-over-ssh.md §5. */
|
||||
connectionId: string | null
|
||||
/** Timestamp (ms) when the hook server received this latest status event. */
|
||||
receivedAt: number
|
||||
/** Timestamp (ms) when the current state first appeared for this pane. */
|
||||
|
||||
@@ -64,9 +64,9 @@ export const MAX_EDITOR_AUTO_SAVE_DELAY_MS = 10_000
|
||||
|
||||
// Why: initial threshold of agents spawned (since last update) before we show
|
||||
// the star-on-GitHub notification. Doubles each time the user dismisses
|
||||
// without starring — e.g. 50 → 100 → 200 → 400. Past dismissals are encoded
|
||||
// without starring — e.g. 35 → 70 → 140 → 280. Past dismissals are encoded
|
||||
// in starNagNextThreshold, so this constant is only the first-time seed.
|
||||
export const STAR_NAG_INITIAL_THRESHOLD = 50
|
||||
export const STAR_NAG_INITIAL_THRESHOLD = 35
|
||||
|
||||
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
|
||||
'status',
|
||||
|
||||
+3
-3
@@ -1181,9 +1181,9 @@ export type GlobalSettings = {
|
||||
promptCacheTtlMs: number
|
||||
/** Why: Codex rate-limit account routing is a durable app preference owned by
|
||||
* the main process, not transient UI state. Persisting the selected managed
|
||||
* homes here lets Orca resolve the correct `CODEX_HOME` before the renderer
|
||||
* hydrates, while keeping this scope explicitly separate from Codex usage
|
||||
* analytics and external terminal sessions. */
|
||||
* auth here lets Orca prepare shared ~/.codex before the renderer hydrates,
|
||||
* while keeping this scope explicitly separate from Codex usage analytics
|
||||
* and external terminal sessions. */
|
||||
codexManagedAccounts: CodexManagedAccount[]
|
||||
activeCodexManagedAccountId: string | null
|
||||
/** Why: Claude Code keeps conversations under one shared config root. Orca
|
||||
|
||||
Reference in New Issue
Block a user