Merge remote-tracking branch 'origin/main' into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-09-01 22:08:48 -04:00
184 changed files with 6241 additions and 1352 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ export type RelocatedDaemonHost = {
const HOST_SUBDIR = 'daemon-host'
const MARKER_NAME = '.materialized.json'
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync.
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
+49
View File
@@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = {
type ExactRefPresence = 'present' | 'absent' | 'unknown'
const EXACT_REF_PROBE_CONCURRENCY = 8
// SHA-1 and SHA-256 repositories both report a full object id here.
const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/
export function isShowRefNoMatchError(error: unknown): boolean {
const record = error && typeof error === 'object' ? (error as Record<string, unknown>) : undefined
@@ -126,3 +128,50 @@ export async function probeAnyExactRef(
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
return { found, unknown }
}
/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */
export type ExactRefProbeStdinExec = (
argv: string[],
options: ExactRefProbeExecOptions & { stdin: string }
) => Promise<{ stdout: string }>
/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data
* rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`.
* A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */
export async function probeAnyExactRefBatched(
runGit: ExactRefProbeStdinExec,
refs: readonly string[],
options: ExactRefProbeExecOptions = {}
): Promise<{ found: boolean; unknown: boolean }> {
const uniqueRefs = [...new Set(refs)]
const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref))
if (safeRefs.length === 0) {
return { found: false, unknown: uniqueRefs.length > 0 }
}
let stdout: string
try {
;({ stdout } = await runGit(['cat-file', '--batch-check'], {
...options,
stdin: `${safeRefs.join('\n')}\n`
}))
} catch {
return { found: false, unknown: true }
}
const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
// One line per input, in order; a short read means the batch never answered for the rest.
if (lines.length !== safeRefs.length) {
return { found: false, unknown: true }
}
let unknown = safeRefs.length !== uniqueRefs.length
for (const line of lines) {
const [head, type] = line.split(' ')
if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') {
return { found: true, unknown: false }
}
if (type !== 'missing') {
// `ambiguous`, or a spelling this Git reports differently; neither proves absence.
unknown = true
}
}
return { found: false, unknown }
}
@@ -0,0 +1,49 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { getBranchConflictKind } from './repo-branch-conflict'
describe('branch conflict real Git contract', () => {
const tempPaths: string[] = []
afterEach(() => {
for (const path of tempPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
it('decides remote conflicts from one batched probe across many remotes', async () => {
const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-'))
tempPaths.push(repoPath)
const git = (...args: string[]): string =>
execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' })
git('init', '--quiet')
git('config', 'user.name', 'Orca Test')
git('config', 'user.email', 'orca@example.test')
git('config', 'commit.gpgSign', 'false')
git('config', 'core.hooksPath', '.git/no-hooks')
writeFileSync(join(repoPath, 'fixture.txt'), 'base\n')
git('add', 'fixture.txt')
git('commit', '--quiet', '-m', 'base')
const head = git('rev-parse', 'HEAD').trim()
// Many remotes is the shape that used to cost one subprocess each.
for (let index = 0; index < 12; index += 1) {
git('remote', 'add', `remote${index}`, 'https://example.test/repo.git')
}
git('update-ref', 'refs/remotes/remote7/taken', head)
await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote')
await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull()
// The allowed base ref is the one remote spelling that is not a conflict.
await expect(
getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken')
).resolves.toBeNull()
git('branch', 'local-only', head)
await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local')
})
})
+120
View File
@@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => {
expect(exec).not.toHaveBeenCalled()
})
})
describe('getBranchConflictKindViaExec batched remote probe', () => {
function remoteNames(count: number): string {
return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n`
}
function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> {
return async (argv) => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
}
it('asks one batched child instead of one probe per remote', async () => {
const calls: string[][] = []
const stdinPayloads: (string | undefined)[] = []
const exec = baseExec(calls)
const batched = async (
argv: string[],
options: { stdin: string }
): Promise<{ stdout: string }> => {
calls.push(argv)
stdinPayloads.push(options.stdin)
return {
stdout: [
'refs/remotes/remote0/feature missing',
'refs/remotes/remote1/feature missing',
'refs/remotes/remote2/feature missing'
].join('\n')
}
}
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBeNull()
expect(calls).toEqual([
['rev-parse', '--verify', 'refs/heads/feature'],
['remote'],
['cat-file', '--batch-check']
])
expect(stdinPayloads).toEqual([
'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n'
])
})
it('reports a remote conflict from the batched answer', async () => {
const calls: string[][] = []
const exec = baseExec(calls)
const batched = async (): Promise<{ stdout: string }> => ({
stdout: [
'refs/remotes/remote0/feature missing',
`${'a'.repeat(40)} commit 214`,
'refs/remotes/remote2/feature missing'
].join('\n')
})
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBe('remote')
})
it('falls back to per-ref probes when the batch cannot answer', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
if (argv[0] === 'show-ref') {
if (argv[4] === 'refs/remotes/remote1/feature') {
return { stdout: 'abc refs/remotes/remote1/feature\n' }
}
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
const batched = async (): Promise<{ stdout: string }> => {
throw new Error('cat-file is unavailable')
}
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBe('remote')
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
})
it('treats a short batch read as undecided rather than as absence', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'rev-parse') {
throw new Error('local branch is absent')
}
if (argv[0] === 'remote') {
return { stdout: remoteNames(3) }
}
if (argv[0] === 'show-ref') {
throw Object.assign(new Error('missing'), { code: 1, stderr: '' })
}
throw new Error(`unexpected git command: ${argv.join(' ')}`)
}
const batched = async (): Promise<{ stdout: string }> => ({
stdout: 'refs/remotes/remote0/feature missing'
})
await expect(
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBeNull()
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
})
})
+43 -10
View File
@@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner'
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
import {
probeAnyExactRef,
probeAnyExactRefBatched,
type ExactRefProbeExec,
type ExactRefProbeExecOptions
type ExactRefProbeExecOptions,
type ExactRefProbeStdinExec
} from './exact-ref-probe'
export type BranchConflictKind = 'local' | 'remote'
@@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs(
return [...refs]
}
/** One batched child answers for every remote; the per-ref probes only run when the host cannot
* feed stdin, or when the batch came back undecided. */
async function probeAnyRemoteConflictRef(
exec: ExactRefProbeExec,
batchedExec: ExactRefProbeStdinExec | undefined,
candidateRefs: readonly string[],
probeOptions: ExactRefProbeExecOptions
): Promise<{ found: boolean }> {
if (batchedExec) {
// A present ref is always decisive, so `found` never survives with `unknown` set.
const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions)
if (!batched.unknown) {
return { found: batched.found }
}
}
return probeAnyExactRef(exec, candidateRefs, probeOptions)
}
/** Run branch-conflict policy through the host that owns Git execution. */
export async function getBranchConflictKindViaExec(
exec: ExactRefProbeExec,
branchName: string,
allowedBaseRef?: string,
options: ExactRefProbeExecOptions = {}
options: ExactRefProbeExecOptions = {},
batchedExec?: ExactRefProbeStdinExec
): Promise<BranchConflictKind | null> {
if (!canQueryRemoteBranchName(branchName)) {
return null
@@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec(
return null
}
const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions)
const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef(
exec,
batchedExec,
candidateRefs,
probeOptions
)
return hasRemoteConflict ? 'remote' : null
} catch {
@@ -119,15 +145,22 @@ export function getBranchConflictKind(
options: LocalGitExecOptions = {}
): Promise<BranchConflictKind | null> {
const execOptions = gitExecOptions(path, options)
const runLocalGit = (
argv: string[],
commandOptions?: ExactRefProbeExecOptions & { stdin?: string }
): Promise<{ stdout: string }> =>
gitExecFileAsync(argv, {
...execOptions,
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }),
...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin })
})
return getBranchConflictKindViaExec(
(argv, commandOptions) =>
gitExecFileAsync(argv, {
...execOptions,
...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }),
...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs })
}),
runLocalGit,
branchName,
allowedBaseRef
allowedBaseRef,
{},
(argv, commandOptions) => runLocalGit(argv, commandOptions)
)
}
+50
View File
@@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots
import { initializeMainProcessReady } from './startup/main-process-ready'
import { installMainProcessQuitHandlers } from './startup/main-process-quit'
import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock'
import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.skillShareDeepLinks.capture(argv, (shareId) => {
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
return
@@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.desktopActivationGate?.requestActivation()
}
/**
* Hands buffered OS-opened markdown paths to a renderer that has proven it is listening.
*
* Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer
* with no listener attached is dropped silently and the queue would be gone.
*/
function publishOsOpenedMarkdownFiles(): void {
const targetWindow = state.mainWindow
if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) {
return
}
// Why consumed before the await: a renderer pull racing this resolve must not take the same
// batch again. The restore() calls hand it back if delivery turns out to be impossible.
const filePaths = state.osOpenedMarkdownFiles.consume()
if (filePaths.length === 0) {
return
}
void resolveOpenedMarkdownDocuments(filePaths)
.then((documents) => {
if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) {
state.osOpenedMarkdownFiles.restore(filePaths)
return
}
if (documents.length > 0) {
targetWindow.webContents.send('ui:openMarkdownFiles', documents)
}
})
.catch((error) => {
state.osOpenedMarkdownFiles.restore(filePaths)
console.warn('[os-open] Failed to resolve OS-opened markdown files:', error)
})
}
const handleMacAppActivation = createMacAppActivationHandler({
getWindow: () => state.mainWindow,
requestActivation: requestDesktopActivation
@@ -53,7 +88,22 @@ if (preflightReady) {
event.preventDefault()
requestDesktopActivation([url])
})
// Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler
// that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins.
app.on('open-file', (event, filePath) => {
if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) {
return
}
event.preventDefault()
// Why gated on isReady: pre-ready the cold-start window is already on its way, and
// activating the gate here would try to open one before Electron can.
if (app.isReady()) {
requestDesktopActivation()
}
})
state.skillShareDeepLinks.capture(process.argv)
// Why no publish: nothing is listening this early, so the first renderer pulls these on mount.
state.osOpenedMarkdownFiles.capture(process.argv)
registerMainProcessIpcHandlers()
installMainProcessQuitHandlers()
void app.whenReady().then(async () => {
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
// Why: the backstop full scan stats a `.git` marker per candidate dir; an
// unbounded fan-out at hundreds of worktrees would queue thousands of `stat`
// calls on libuv's 4-thread pool (#17828).
const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
try {
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
function makeTarget(path: string): WorktreeBaseWatchTarget {
const repoConfig: WorktreeBaseRepoWatchConfig = {
repoId: 'repo-1',
repoName: 'project',
nestWorkspaces: false
}
return {
key: `base:local:${path}`,
kind: 'base',
path,
repos: new Map([[repoConfig.repoId, repoConfig]])
}
}
describe('worktree base directory poller marker fan-out (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
beforeEach(() => {
concurrency.current = 0
concurrency.peak = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
})
it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-')))
cleanups.push(() => rm(root, { recursive: true, force: true }))
const candidateCount = 200
for (let i = 0; i < candidateCount; i++) {
const worktree = join(root, `wt-${i}`)
await mkdir(worktree)
await writeFile(join(worktree, '.git'), 'gitdir: elsewhere')
}
const target = makeTarget(root)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
() => {},
{ pollIntervalMs: 100_000 }
)
cleanups.push(() => poller.unsubscribe())
// 200 candidates stated unbounded would peak near 200 concurrent `stat`
// calls; bounding the marker probe keeps the peak independent of count —
// while still overlapping requests (not serialized one-at-a-time).
expect(concurrency.peak).toBeGreaterThan(1)
expect(concurrency.peak).toBeLessThan(20)
})
})
+21 -16
View File
@@ -1,6 +1,8 @@
import { readdir, stat } from 'node:fs/promises'
import type { Dirent } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
import type {
WorktreeBaseRepoWatchConfig,
@@ -92,6 +94,11 @@ export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
// Why: matches the git-common poller's fan-out bound (#17828) — bounded
// concurrency turns hundreds of serial round trips into a handful of batches
// without dumping every candidate onto libuv's 4-thread pool at once.
const MARKER_PROBE_CONCURRENCY = 8
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
@@ -123,6 +130,14 @@ type BaseSnapshot = {
gateSignatures: string[]
}
async function readdirSafe(path: string): Promise<Dirent[]> {
try {
return await readdir(path, { withFileTypes: true })
} catch {
return []
}
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
@@ -144,14 +159,9 @@ async function snapshotBase(
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
let rootEntries
try {
rootEntries = await readdir(rootPath, { withFileTypes: true })
} catch {
// Root vanished: an empty snapshot diffs into delete events for every
// previously-known worktree dir, matching the old watcher's error path.
return { markers, gateDirs, gateSignatures }
}
// Root vanished or unreadable: readdirSafe yields [], producing the same
// empty markers/candidates result as the old watcher's error path.
const rootEntries = await readdirSafe(rootPath)
const candidates: string[] = []
for (const entry of rootEntries) {
@@ -165,12 +175,7 @@ async function snapshotBase(
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
let subEntries
try {
subEntries = await readdir(entryPath, { withFileTypes: true })
} catch {
subEntries = []
}
const subEntries = await readdirSafe(entryPath)
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
@@ -179,9 +184,9 @@ async function snapshotBase(
}
}
for (const dir of candidates) {
await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => {
markers.set(dir, await hasGitMarker(dir))
}
})
return { markers, gateDirs, gateSignatures }
}
@@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry(
previous: GitCommonEntrySnapshot | undefined,
forceFullScan: boolean
): Promise<GitCommonEntrySnapshot> {
// Structural leaves change in place every tick; only index uses the entry-dir gate.
// Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the
// entry dir, so the entry dir's own signature moves on every one of those writes
// (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash,
// worktree lock/unlock, config --worktree, index writes all move it). The one
// in-place exception is `gitdir` (worktree move/repair), which the periodic
// forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this
// gate. Gating all of these leaves on the entry-dir signature turns an unchanged
// entry into a single stat per tick instead of stat-ing every leaf every tick.
const nextDirSignature = await gitCommonDirectorySignature(entryPath)
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures: new Map(),
indexSignature: null,
headLogSignature: null
}
)
}
const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
if (!shouldRescan) {
return previous
}
const structuralSignatures = new Map<string, string>()
const [nextDirSignature, headLogSignature] = await Promise.all([
gitCommonDirectorySignature(entryPath),
const [headLogSignature, indexSignature] = await Promise.all([
gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)),
gitCommonFileSignature(join(entryPath, INDEX_FILE)),
Promise.all(
STRUCTURAL_METADATA_FILES.map(async (name) => {
const signature = await gitCommonFileSignature(join(entryPath, name))
@@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry(
})
)
])
if (nextDirSignature === 'missing') {
return (
previous ?? {
dirSignature: nextDirSignature,
structuralSignatures,
indexSignature: null,
headLogSignature
}
)
}
const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature
const indexSignature = shouldReadIndex
? await gitCommonFileSignature(join(entryPath, INDEX_FILE))
: previous.indexSignature
return {
dirSignature: nextDirSignature,
structuralSignatures,
@@ -73,6 +73,11 @@ export async function startGitCommonNarrowWatch(
.unsubscribe()
.catch(() => {})
.then(() =>
// Crash fuse tripped: this poller is now the sole change signal until a
// future existence-poll upgrade (follow-up: #17878). Its own per-entry
// dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps
// an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence
// stays cheap at high worktree counts without needing to stretch itself.
startGitCommonPolling(
target.path,
onEvents,
@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { startGitCommonPolling } from './worktree-git-common-polling'
import type {
WorktreeBasePollEvent,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Why: measure the fan-out this poller issues per scan (peak concurrent `stat`
// calls, `readdir` call count as a proxy for "a tick ran") without depending on
// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a
// specific pre-existing entry (its dir plus every leaf), used to prove the
// entry-dir signature gate keeps an unchanged entry to one stat per tick.
const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({
statDelayMs: { current: 0 },
readdirCalls: { count: 0 },
concurrency: { current: 0, peak: 0 },
entryZeroStatCalls: { count: 0 }
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
readdir: (...args: Parameters<typeof actual.readdir>) => {
readdirCalls.count += 1
return actual.readdir(...args)
},
stat: async (...args: Parameters<typeof actual.stat>) => {
concurrency.current += 1
concurrency.peak = Math.max(concurrency.peak, concurrency.current)
const path = args[0]
const entryZeroSegment = `${sep}wt-0`
if (
typeof path === 'string' &&
(path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`))
) {
entryZeroStatCalls.count += 1
}
try {
if (statDelayMs.current > 0) {
await new Promise((resolve) => setTimeout(resolve, statDelayMs.current))
}
return await actual.stat(...args)
} finally {
concurrency.current -= 1
}
}
}
})
const alwaysVisible: WorktreePollerWindowVisibility = {
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
}
async function makeCommonDir(entryCount: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-'))
for (let i = 0; i < entryCount; i++) {
const entryPath = join(root, 'worktrees', `wt-${i}`)
await mkdir(join(entryPath, 'logs'), { recursive: true })
await Promise.all([
writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'),
writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`),
writeFile(join(entryPath, 'index'), Buffer.from([0])),
writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n')
])
}
return root
}
describe('startGitCommonPolling fan-out bounds (#17828)', () => {
const cleanups: (() => Promise<void>)[] = []
const dirsToRemove: string[] = []
beforeEach(() => {
statDelayMs.current = 0
readdirCalls.count = 0
concurrency.current = 0
concurrency.peak = 0
entryZeroStatCalls.count = 0
})
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))
await Promise.all(
dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
)
vi.useRealTimers()
})
it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => {
const commonDir = await makeCommonDir(200)
dirsToRemove.push(commonDir)
const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// 200 entries x ~6 concurrent structural stats each would peak near 1,200
// unbounded; bounding to 8 in-flight entries keeps the peak independent of
// entry count instead of scaling with it.
expect(concurrency.peak).toBeLessThan(80)
})
it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => {
const commonDir = await makeCommonDir(10)
dirsToRemove.push(commonDir)
statDelayMs.current = 20
const pollIntervalMs = 5
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
readdirCalls.count = 0
// ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms;
// the ticking guard must serialize scans, not launch overlapping ones.
await new Promise((resolve) => setTimeout(resolve, 300))
expect(readdirCalls.count).toBeLessThan(10)
})
it('costs exactly one stat per tick for an unchanged entry', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const pollIntervalMs = 20
const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot (which always fully reads every entry once) settle.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
readdirCalls.count = 0
entryZeroStatCalls.count = 0
await vi.waitFor(
() => {
expect(readdirCalls.count).toBeGreaterThanOrEqual(5)
},
{ timeout: 2_000 }
)
// Without the entry-dir signature gate, an unchanged entry still costs ~6
// stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index).
// With the gate, only the entry dir itself is stat'd once nothing changed —
// one stat per tick, in lockstep with the readdir tripwire.
expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1)
expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1)
})
it('detects a HEAD rewrite via lock+rename on the next tick', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 20
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const headPath = join(entryDir, 'HEAD')
const headLockPath = join(entryDir, 'HEAD.lock')
// Every real git ref write goes through a lock file + rename inside the entry
// dir (never an in-place overwrite), which moves the entry dir's own signature.
await writeFile(headLockPath, 'ref: refs/heads/feature\n')
await rename(headLockPath, headPath)
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: headPath })
},
{ timeout: pollIntervalMs * 10 }
)
})
it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => {
const commonDir = await makeCommonDir(1)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const pollIntervalMs = 10
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
pollIntervalMs,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
// Let the bootstrap snapshot settle before mutating.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
const entryDir = join(commonDir, 'worktrees', 'wt-0')
const gitdirPath = join(entryDir, 'gitdir')
// `gitdir` is the one structural leaf git rewrites in place (worktree move/repair),
// so the entry dir's own signature never moves — the periodic ungated backstop
// (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it.
await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`)
// Not caught by the next several ticks: the gate stays closed since nothing
// moved the entry dir's own signature.
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5))
expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath })
// Eventually caught regardless of the gate, once tick 15 forces the periodic backstop.
await vi.waitFor(
() => {
expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath })
},
{ timeout: pollIntervalMs * 40 }
)
})
it('still detects entry add/remove correctly with bounded concurrency', async () => {
const commonDir = await makeCommonDir(5)
dirsToRemove.push(commonDir)
const events: WorktreeBasePollEvent[][] = []
const sub = await startGitCommonPolling(
commonDir,
(batch) => events.push(batch),
20,
alwaysVisible
)
cleanups.push(() => sub.unsubscribe())
const newEntry = join(commonDir, 'worktrees', 'wt-new')
await mkdir(join(newEntry, 'logs'), { recursive: true })
await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n')
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'create', path: newEntry })
})
await rm(newEntry, { recursive: true })
await vi.waitFor(() => {
expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry })
})
})
})
+21 -14
View File
@@ -1,5 +1,6 @@
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files'
import {
diffGitCommon,
@@ -23,18 +24,26 @@ import {
// same way the base poller's backstop rescan does.
const INDEX_BACKSTOP_TICKS = 15
// Why: an unbounded fan-out across every worktree admin entry queues thousands
// of ops on libuv's 4-thread default pool, starving every other main-process
// fs call for the scan's duration (#17828). 8 mirrors the existing
// head-identity/exact-ref-probe pools — enough to saturate typical local
// disks without monopolizing the pool. Since snapshotGitCommonEntry's own
// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks
// down to 1 stat per unchanged entry, real in-flight is now bounded by this
// limit rather than limit × per-entry stat count.
const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8
async function snapshotStatusRefSignatures(
paths: ReadonlySet<string>
): Promise<Map<string, string>> {
const signatures = new Map<string, string>()
await Promise.all(
[...paths].map(async (path) => {
const signature = await gitCommonFileSignature(path)
if (signature !== null) {
signatures.set(path, signature)
}
})
)
await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => {
const signature = await gitCommonFileSignature(path)
if (signature !== null) {
signatures.set(path, signature)
}
})
return signatures
}
@@ -91,12 +100,10 @@ async function snapshotGitCommon(
}
const entries = new Map<string, GitCommonEntrySnapshot>()
await Promise.all(
entryPaths.map(async (entryPath) => {
const previousEntry = previous?.entries.get(entryPath)
entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan))
})
)
await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => {
const previousEntry = previous?.entries.get(entryPath)
entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan))
})
// Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan
// now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost —
// rather than the always-run worktrees-dir readdir.
@@ -60,6 +60,8 @@ export async function startGitCommonWatch(
}
}
}
// Why: Electron only ships darwin/linux/win32, all covered by NARROW_WATCH_PLATFORMS
// above, so this branch is defensive dead code in production, not a reachable fallback.
return startGitCommonPolling(
target.path,
onEvents,
@@ -4,6 +4,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import {
cleanupUnusedWorktreePushTargetRemoteWithExec,
findWorktreeMetaReferencingRemote,
hasBranchConfigUsingRemote,
sameGitHubRemoteUrl,
type GitRemoteExec,
type WorktreePushTargetStore
@@ -253,6 +255,70 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => {
})
})
describe('hasBranchConfigUsingRemote', () => {
it('requireExistingBranch: false (default) protects on config alone, even if the branch is gone', async () => {
const exec = makeExec({ branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}` })
await expect(hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget())).resolves.toBe(true)
})
it('requireExistingBranch: true only protects when the referencing branch still exists', async () => {
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: 'main\ncontributor/fix\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await expect(
hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true })
).resolves.toBe(true)
})
it('requireExistingBranch: true does not protect on a stale config entry from a deleted branch', async () => {
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: 'main\n', stderr: '' } // contributor/fix no longer exists
}
return { stdout: '', stderr: '' }
})
await expect(
hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true })
).resolves.toBe(false)
})
it('extracts branch names containing dots correctly', async () => {
const exec = vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'config') {
return { stdout: `branch.release/1.2.3.remote ${FORK_REMOTE}`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: 'release/1.2.3\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
await expect(
hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true })
).resolves.toBe(true)
})
})
describe('findWorktreeMetaReferencingRemote', () => {
it('scopes matches to the given repo id and excludes worktrees without a pushTarget', () => {
const store = storeOf({
'repo-1::/wt/a': forkTarget(),
'repo-1::/wt/b': undefined,
'repo-2::/wt/c': forkTarget()
})
const matches = findWorktreeMetaReferencingRemote(store, 'repo-1', forkTarget())
expect(matches.map((match) => match.worktreeId)).toEqual(['repo-1::/wt/a'])
})
})
describe('sameGitHubRemoteUrl', () => {
it('matches SSH and HTTPS forms of the same GitHub fork', () => {
expect(
+108 -30
View File
@@ -1,9 +1,12 @@
// Why: fork-PR worktrees can add a contributor's fork as a git remote. When such
// a worktree is deleted we prune that remote, but only when it's truly unused.
// This module holds that decision logic behind an injectable `execGit` boundary so
// the multi-fork cleanup matrix is unit-testable without a real repo.
// the multi-fork cleanup matrix is unit-testable without a real repo. The same
// predicates back the periodic sweep in `worktree-push-target-reconciliation.ts`,
// which inverts them over every `pr-*` remote instead of one removed worktree.
import type { Store } from '../persistence'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import { parseGitHubOwnerRepo } from '../github/gh-utils'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
@@ -29,61 +32,113 @@ export function sameGitHubRemoteUrl(left: string, right: string): boolean {
)
}
/** A worktree metadata entry, in the same repo as `target`, whose pushTarget references it. */
export type WorktreeMetaReferencingRemote = { worktreeId: string; meta: WorktreeMeta }
// Exported so the reconciliation sweep can inspect *which* worktrees reference a remote
// (to check liveness/provenance) instead of only the single-target yes/no this file needs.
export function findWorktreeMetaReferencingRemote(
store: WorktreePushTargetStore,
repoId: string,
target: Pick<GitPushTarget, 'remoteName' | 'remoteUrl'>
): WorktreeMetaReferencingRemote[] {
return Object.entries(store.getAllWorktreeMeta())
.filter(([worktreeId, meta]) => {
// Why: git remotes are repo-local; matching metadata from another repo
// must not pin this repo's fork remote forever.
if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) {
return false
}
const otherRemoteUrl = meta.pushTarget.remoteUrl
const targetRemoteUrl = target.remoteUrl
return (
meta.pushTarget.remoteName === target.remoteName ||
(typeof otherRemoteUrl === 'string' &&
typeof targetRemoteUrl === 'string' &&
sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl))
)
})
.map(([worktreeId, meta]) => ({ worktreeId, meta }))
}
function isPushTargetUsedByAnotherWorktree(
store: WorktreePushTargetStore,
removedWorktreeId: string,
target: GitPushTarget
): boolean {
const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId)
return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => {
// Why: git remotes are repo-local; matching metadata from another repo
// must not pin this repo's fork remote forever.
const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId
if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) {
return false
}
const otherRemoteUrl = meta.pushTarget.remoteUrl
const targetRemoteUrl = target.remoteUrl
return (
meta.pushTarget.remoteName === target.remoteName ||
(typeof otherRemoteUrl === 'string' &&
typeof targetRemoteUrl === 'string' &&
sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl))
)
})
return findWorktreeMetaReferencingRemote(store, removedRepoId, target).some(
({ worktreeId }) => worktreeId !== removedWorktreeId
)
}
async function hasBranchConfigUsingRemote(
export type BranchConfigMatch = { branchName: string }
// Exported for the sweep, which additionally verifies each matched branch still exists
// before treating it as a reason to keep the remote (`requireExistingBranch`).
export async function hasBranchConfigUsingRemote(
execGit: GitRemoteExec,
repoPath: string,
target: GitPushTarget
target: Pick<GitPushTarget, 'remoteName' | 'remoteUrl'>,
options: { requireExistingBranch?: boolean } = {}
): Promise<boolean> {
let stdout: string
try {
;({ stdout } = await execGit(
['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'],
repoPath
))
} catch {
return false
}
const matches: BranchConfigMatch[] = []
// Why: git config output can be large; avoid materializing line/split arrays here.
for (const line of iterateProcessOutputLines(stdout)) {
const parsed = parseBranchRemoteConfigLine(line)
if (parsed && (parsed.value === target.remoteName || parsed.value === target.remoteUrl)) {
matches.push({ branchName: parsed.branchName })
}
}
if (matches.length === 0) {
return false
}
if (!options.requireExistingBranch) {
return true
}
return branchesExist(
execGit,
repoPath,
matches.map((match) => match.branchName)
)
}
async function branchesExist(
execGit: GitRemoteExec,
repoPath: string,
branchNames: string[]
): Promise<boolean> {
try {
const { stdout } = await execGit(
['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'],
['for-each-ref', '--format=%(refname:short)', 'refs/heads/'],
repoPath
)
// Why: git config output can be large; avoid materializing line/split arrays here.
for (const line of iterateProcessOutputLines(stdout)) {
const value = readBranchRemoteConfigValue(line)
if (value === target.remoteName || value === target.remoteUrl) {
return true
}
}
return false
const existingBranches = new Set(iterateProcessOutputLines(stdout))
return branchNames.some((branchName) => existingBranches.has(branchName))
} catch {
return false
}
}
function readBranchRemoteConfigValue(line: string): string | null {
function parseBranchRemoteConfigLine(line: string): { branchName: string; value: string } | null {
let index = 0
while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
const keyStart = index
while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
const key = line.slice(keyStart, index)
while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) {
index += 1
}
@@ -96,7 +151,30 @@ function readBranchRemoteConfigValue(line: string): string | null {
while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) {
valueEnd -= 1
}
return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null
if (valueStart >= valueEnd) {
return null
}
const branchName = extractBranchNameFromConfigKey(key)
return branchName ? { branchName, value: line.slice(valueStart, valueEnd) } : null
}
// `branch.<name>.remote` / `branch.<name>.pushRemote`; `<name>` may itself contain dots
// (e.g. `release/1.2.3`), so only the known trailing suffix is stripped.
function extractBranchNameFromConfigKey(key: string): string | null {
const prefix = 'branch.'
if (!key.startsWith(prefix)) {
return null
}
const rest = key.slice(prefix.length)
const lastDot = rest.lastIndexOf('.')
if (lastDot <= 0) {
return null
}
const suffix = rest.slice(lastDot + 1)
if (suffix !== 'remote' && suffix !== 'pushRemote') {
return null
}
return rest.slice(0, lastDot)
}
function isBranchConfigSeparator(code: number): boolean {
@@ -0,0 +1,173 @@
// Real-binary coverage for the pr-* remote reconciliation sweep (#17828): the mocked-exec suite
// proves the decision matrix, but not that `git remote -v`, `git config --get-regexp`, and
// `git for-each-ref` are parsed correctly against real Git output.
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup'
import { reconcileOrphanedPrRemotesWithExec } from './worktree-push-target-reconciliation'
const execFileAsync = promisify(execFile)
const REPO_ID = 'repo-1'
const FORK_REMOTE = 'pr-contributor-orca'
let scratchDir = ''
let repoPath = ''
let forkPath = ''
async function git(args: string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
const execGit: GitRemoteExec = (args, cwd) => execFileAsync('git', args, { cwd })
function worktreeId(suffix: string): string {
return `${REPO_ID}::${suffix}`
}
function forkTarget(overrides: Partial<GitPushTarget> = {}): GitPushTarget {
return {
remoteName: FORK_REMOTE,
branchName: 'contributor/fix',
remoteUrl: forkPath,
remoteCreated: true,
...overrides
}
}
function storeOf(entries: Record<string, GitPushTarget | undefined>): WorktreePushTargetStore {
const meta: Record<string, WorktreeMeta> = {}
for (const [id, pushTarget] of Object.entries(entries)) {
meta[id] = { pushTarget } as unknown as WorktreeMeta
}
return { getAllWorktreeMeta: () => meta }
}
beforeEach(async () => {
// realpath: macOS hands out /var/... temp paths while Git reports /private/var/...
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pr-remote-reconcile-')))
repoPath = join(scratchDir, 'repo')
forkPath = join(scratchDir, 'fork')
await mkdir(repoPath, { recursive: true })
await git(['init', '-q'], repoPath)
await git(['config', 'user.name', 'Orca Test'], repoPath)
await git(['config', 'user.email', 'orca@example.test'], repoPath)
await git(['config', 'commit.gpgSign', 'false'], repoPath)
await git(['config', 'core.hooksPath', '.git/no-hooks'], repoPath)
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
await git(['add', '-A'], repoPath)
await git(['commit', '-qm', 'seed'], repoPath)
// A second local "fork" repo the pr-* remote points at, so `remote add`/fetch behave normally.
await git(['clone', '-q', repoPath, forkPath], scratchDir)
await git(['config', 'user.name', 'Orca Test'], forkPath)
await git(['config', 'user.email', 'orca@example.test'], forkPath)
await git(['config', 'commit.gpgSign', 'false'], forkPath)
await git(['config', 'core.hooksPath', '.git/no-hooks'], forkPath)
await git(['checkout', '-qb', 'contributor/fix'], forkPath)
await writeFile(join(forkPath, 'fork.txt'), 'fork change\n')
await git(['add', '-A'], forkPath)
await git(['commit', '-qm', 'fork change'], forkPath)
await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath)
await git(
[
'fetch',
FORK_REMOTE,
`+refs/heads/contributor/fix:refs/remotes/${FORK_REMOTE}/contributor/fix`
],
repoPath
)
})
afterEach(async () => {
await rm(scratchDir, { recursive: true, force: true })
})
describe('reconcileOrphanedPrRemotesWithExec against the real Git binary', () => {
it('leaves a user-created remote alone: naming/URL shape is not proof of provenance', async () => {
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({}), // no worktree metadata anywhere claims this remote
execGit,
[]
)
expect(reclaimed).toEqual([])
await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE)
})
it('leaves the remote alone while a live worktree still references it', async () => {
const worktreePath = join(scratchDir, 'wt-live')
await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local'], repoPath)
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(worktreePath)]: forkTarget() }),
execGit,
[worktreePath]
)
expect(reclaimed).toEqual([])
await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE)
})
it('leaves the remote alone while its branch still exists (preserve-on-delete kept alive)', async () => {
await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath)
await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath)
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }),
execGit,
[] // the worktree that created it is gone, but the branch it preserved is not
)
expect(reclaimed).toEqual([])
await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE)
})
it('reclaims the remote once the branch that pinned it is deleted (path 2)', async () => {
await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath)
await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath)
// Delete only the ref, leaving the config behind, exactly as `update-ref -d` alone would --
// proving the sweep checks branch existence rather than trusting stale config.
await rm(join(repoPath, '.git', 'refs', 'heads', 'contributor', 'fix'))
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }),
execGit,
[]
)
expect(reclaimed).toEqual([FORK_REMOTE])
await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE)
})
it('reclaims a remote orphaned by a worktree removed outside Orca (path 3)', async () => {
const worktreePath = join(scratchDir, 'wt-externally-removed')
await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local-2'], repoPath)
// Simulate a plain `git worktree remove` the user ran outside Orca: Orca's metadata for
// that worktree is still sitting in the store (nothing told it to clean up), but the
// worktree itself is gone.
await git(['worktree', 'remove', '--force', worktreePath], repoPath)
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
repoPath,
REPO_ID,
storeOf({ [worktreeId(worktreePath)]: forkTarget() }),
execGit,
[] // listWorktrees no longer reports it
)
expect(reclaimed).toEqual([FORK_REMOTE])
await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE)
})
})
@@ -0,0 +1,262 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import { validateGitExecArgs } from '../../relay/git-exec-validator'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup'
import {
_resetPrRemoteReconciliationRateLimitForTests,
isOrcaGeneratedPrRemoteName,
reconcileOrphanedPrRemotesWithExec
} from './worktree-push-target-reconciliation'
type ExecMock = Mock<GitRemoteExec>
const REPO_PATH = '/repo-root'
const REPO_ID = 'repo-1'
const FORK_URL = 'git@github.com:contributor/orca.git'
const FORK_REMOTE = 'pr-contributor-orca'
function worktreeId(suffix: string): string {
return `${REPO_ID}::${suffix}`
}
function forkTarget(overrides: Partial<GitPushTarget> = {}): GitPushTarget {
return {
remoteName: FORK_REMOTE,
branchName: 'contributor/fix',
remoteUrl: FORK_URL,
remoteCreated: true,
...overrides
}
}
function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta {
return { pushTarget } as unknown as WorktreeMeta
}
function storeOf(entries: Record<string, GitPushTarget | undefined>): WorktreePushTargetStore {
const meta: Record<string, WorktreeMeta> = {}
for (const [id, pushTarget] of Object.entries(entries)) {
meta[id] = metaWith(pushTarget)
}
return { getAllWorktreeMeta: () => meta }
}
type ExecScript = {
remotes?: string
branchConfig?: string
localBranches?: string
}
function makeExec(script: ExecScript = {}): ExecMock {
const { remotes = '', branchConfig = '', localBranches = '' } = script
return vi.fn<GitRemoteExec>(async (args: string[]) => {
if (args[0] === 'remote' && args[1] === '-v') {
return { stdout: remotes, stderr: '' }
}
if (args[0] === 'config') {
return { stdout: branchConfig, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: localBranches, stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'remove') {
return { stdout: '', stderr: '' }
}
return { stdout: '', stderr: '' }
})
}
function remoteLines(entries: { name: string; url: string }[]): string {
return entries
.flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
.join('\n')
}
function removeCalls(exec: ExecMock): string[][] {
return exec.mock.calls
.map(([args]) => args)
.filter((args) => args[0] === 'remote' && args[1] === 'remove')
}
describe('isOrcaGeneratedPrRemoteName', () => {
it('matches Orca-generated names, including disambiguated ones', () => {
expect(isOrcaGeneratedPrRemoteName('pr-head')).toBe(true)
expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca')).toBe(true)
expect(isOrcaGeneratedPrRemoteName('pr-head-2')).toBe(true)
expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca-3')).toBe(true)
})
it('does not match unrelated remote names', () => {
expect(isOrcaGeneratedPrRemoteName('origin')).toBe(false)
expect(isOrcaGeneratedPrRemoteName('upstream')).toBe(false)
expect(isOrcaGeneratedPrRemoteName('project-remote')).toBe(false)
})
})
describe('reconcileOrphanedPrRemotesWithExec', () => {
it('leaves a remote alone when no worktree metadata ever proves Orca created it (user-created, ambiguous)', async () => {
// Same naming shape a user could coincidentally pick; no pushTarget anywhere claims it.
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({}),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('leaves a remote alone that is not shaped like an Orca-generated pr-* remote', async () => {
const exec = makeExec({
remotes: remoteLines([{ name: 'my-fork', url: FORK_URL }])
})
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/a')]: { ...forkTarget(), remoteName: 'my-fork' } }),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('leaves a remote alone that a live worktree still references (path guard)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/a')]: forkTarget() }),
exec,
['/wt/a']
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('leaves a remote alone that is referenced by an existing branch (path 2, branch still kept)', async () => {
const exec = makeExec({
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
localBranches: 'contributor/fix\nmain'
})
// Metadata for the worktree that created it is gone, but the branch it preserved lives on.
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('reclaims a remote whose protecting branch config is stale (path 2, branch since deleted)', async () => {
const exec = makeExec({
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
// Config line survives even though `contributor/fix` no longer exists.
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
localBranches: 'main'
})
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[]
)
expect(reclaimed).toEqual([FORK_REMOTE])
expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]])
})
it('reclaims a remote left behind by a worktree removed outside Orca (path 3)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
// Metadata still records the (now-vanished) worktree's Orca-created pushTarget.
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[] // no live worktrees at all
)
expect(reclaimed).toEqual([FORK_REMOTE])
expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]])
})
it('reclaims a remote even when the only referencing metadata lacks remoteCreated, as long as another entry proves provenance (path 1)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({
// The worktree whose removal originally bailed (legacy metadata, no remoteCreated flag)...
[worktreeId('/wt/legacy')]: forkTarget({ remoteCreated: false }),
// ...but a sibling that reused the remote correctly inherited ownership, and is also gone.
[worktreeId('/wt/sibling-gone')]: forkTarget({ remoteCreated: true })
}),
exec,
[]
)
expect(reclaimed).toEqual([FORK_REMOTE])
})
it('never touches origin or upstream even if metadata is malformed', async () => {
const exec = makeExec({
remotes: remoteLines([
{ name: 'origin', url: FORK_URL },
{ name: 'upstream', url: FORK_URL }
])
})
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteName: 'origin' }) }),
exec,
[]
)
expect(reclaimed).toEqual([])
expect(removeCalls(exec)).toEqual([])
})
it('scopes provenance and liveness to the same repo (remotes are repo-local)', async () => {
const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) })
const reclaimed = await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ 'repo-2::/wt/other-repo': forkTarget() }),
exec,
[]
)
expect(reclaimed).toEqual([])
})
it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => {
// Exercise every branch (config probe, for-each-ref probe, and the reclaim itself).
const exec = makeExec({
remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]),
branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`,
localBranches: 'main'
})
await reconcileOrphanedPrRemotesWithExec(
REPO_PATH,
REPO_ID,
storeOf({ [worktreeId('/wt/gone')]: forkTarget() }),
exec,
[]
)
expect(exec.mock.calls.length).toBeGreaterThan(0)
for (const [args] of exec.mock.calls) {
expect(() => validateGitExecArgs(args)).not.toThrow()
}
})
})
describe('reconcileOrphanedPrRemotes rate limiting', () => {
it('exposes a test reset so repeated test runs are not affected by prior cooldowns', () => {
expect(() => _resetPrRemoteReconciliationRateLimitForTests()).not.toThrow()
})
})
@@ -0,0 +1,204 @@
// Why: `pr-*` remotes Orca adds for fork-PR review are only ever pruned by
// `worktree-push-target-cleanup.ts`, and only when a *single* worktree removal
// triggers it. Three things escape that: (1) legacy/reused metadata missing the
// `remoteCreated` flag, (2) a "preserve branch on delete" pinning its remote via
// `branch.*.remote` config long after the worktree is gone, and (3) a worktree
// removed outside Orca entirely (no removal event ever fires). This sweep
// inverts the same safety predicates over every `pr-*` remote in the repo
// instead of one removal, so all three eventually get reclaimed. It never adds
// new safety logic — see `worktree-push-target-cleanup.ts` for the predicates.
import { gitExecFileAsync } from '../git/runner'
import { listWorktrees } from '../git/worktree'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import type { GitPushTarget } from '../../shared/worktree/types'
import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id'
import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
import {
findWorktreeMetaReferencingRemote,
hasBranchConfigUsingRemote,
type GitRemoteExec,
type WorktreePushTargetStore
} from './worktree-push-target-cleanup'
// Orca only ever mints `pr-head` or `pr-<owner>-<repo>` (see `sanitizeRemoteName`), optionally
// disambiguated with `-2`..`-99` (see `ensureUniqueRemoteName`). The naming convention alone is
// not proof of provenance -- a user could name a remote `pr-foo` -- so this only narrows which
// remotes are even considered; `hasOrcaCreatedProvenance` below is the actual safety gate.
const ORCA_PR_REMOTE_NAME_PATTERN =
/^pr-(?:head|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)(?:-[0-9]{1,2})?$/
export function isOrcaGeneratedPrRemoteName(name: string): boolean {
return ORCA_PR_REMOTE_NAME_PATTERN.test(name)
}
type PrRemoteCandidate = { name: string; url: string }
async function listPrRemoteCandidates(
execGit: GitRemoteExec,
repoPath: string
): Promise<PrRemoteCandidate[]> {
let stdout: string
try {
;({ stdout } = await execGit(['remote', '-v'], repoPath))
} catch {
return []
}
const candidates = new Map<string, string>()
for (const line of iterateProcessOutputLines(stdout)) {
const parsed = parseRemoteVerboseLine(line)
if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) {
candidates.set(parsed.name, parsed.url)
}
}
return [...candidates.entries()].map(([name, url]) => ({ name, url }))
}
function parseRemoteVerboseLine(
line: string
): { name: string; url: string; direction: 'fetch' | 'push' } | null {
const tabIndex = line.indexOf('\t')
if (tabIndex === -1) {
return null
}
const name = line.slice(0, tabIndex)
const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim())
return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null
}
async function shouldReclaimPrRemote(
execGit: GitRemoteExec,
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
remote: PrRemoteCandidate,
liveWorktreeKeys: ReadonlySet<string>
): Promise<boolean> {
const target: Pick<GitPushTarget, 'remoteName' | 'remoteUrl'> = {
remoteName: remote.name,
remoteUrl: remote.url
}
const referencingEntries = findWorktreeMetaReferencingRemote(store, repoId, target)
// Provenance gate: only touch a remote some worktree's persisted pushTarget explicitly
// recorded Orca creating. Naming and URL shape are necessary but not sufficient proof.
if (!referencingEntries.some(({ meta }) => meta.pushTarget?.remoteCreated === true)) {
return false
}
const stillClaimedByLiveWorktree = referencingEntries.some(({ worktreeId }) => {
const key = worktreeIdComparisonKey(worktreeId)
return key !== null && liveWorktreeKeys.has(key)
})
if (stillClaimedByLiveWorktree) {
return false
}
// A branch that still exists may push to this fork again later; only a branch that's
// actually gone (force-deleted, or deleted outside the "preserve on delete" flow) frees it.
if (
await hasBranchConfigUsingRemote(execGit, repoPath, target, { requireExistingBranch: true })
) {
return false
}
return true
}
// Exported for unit/real-git tests: the `execGit` seam and injected live-worktree paths let
// tests drive the sweep without a real repo (or with one, for the real-git coverage).
export async function reconcileOrphanedPrRemotesWithExec(
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
execGit: GitRemoteExec,
liveWorktreePaths: readonly string[]
): Promise<string[]> {
const liveWorktreeKeys = new Set(
liveWorktreePaths
.map((path) => worktreeIdComparisonKey(`${repoId}${WORKTREE_ID_SEPARATOR}${path}`))
.filter((key): key is string => key !== null)
)
const reclaimed: string[] = []
for (const remote of await listPrRemoteCandidates(execGit, repoPath)) {
if (await shouldReclaimPrRemote(execGit, repoPath, repoId, store, remote, liveWorktreeKeys)) {
await execGit(['remote', 'remove', remote.name], repoPath)
reclaimed.push(remote.name)
}
}
return reclaimed
}
// Why: the sweep costs a handful of git subprocesses (remote -v, worktree list, per-candidate
// config/for-each-ref); bound to once per repo per cooldown so bursts of removals don't repeat it.
const RECONCILE_COOLDOWN_MS = 60 * 60 * 1000
const lastReconciledAtByRepoId = new Map<string, number>()
function shouldReconcileNow(repoId: string): boolean {
const last = lastReconciledAtByRepoId.get(repoId)
return last === undefined || Date.now() - last >= RECONCILE_COOLDOWN_MS
}
export function _resetPrRemoteReconciliationRateLimitForTests(): void {
lastReconciledAtByRepoId.clear()
}
function logReclaimed(repoPath: string, reclaimed: string[]): void {
if (reclaimed.length > 0) {
console.log(
`[worktrees] Reclaimed ${reclaimed.length} orphaned PR remote(s) in ${repoPath}: ${reclaimed.join(', ')}`
)
}
}
/** Best-effort, rate-limited sweep run alongside single-target cleanup (see call sites). */
export async function reconcileOrphanedPrRemotes(
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
gitOptions: { wslDistro?: string } = {}
): Promise<void> {
if (!shouldReconcileNow(repoId)) {
return
}
lastReconciledAtByRepoId.set(repoId, Date.now())
try {
const liveWorktrees = await listWorktrees(repoPath, gitOptions)
logReclaimed(
repoPath,
await reconcileOrphanedPrRemotesWithExec(
repoPath,
repoId,
store,
(args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }),
liveWorktrees.map((worktree) => worktree.path)
)
)
} catch (error) {
console.warn(`[worktrees] Failed to reconcile orphaned PR remotes for ${repoPath}`, error)
}
}
/** SSH counterpart of {@link reconcileOrphanedPrRemotes}; the execution host owns the remotes. */
export async function reconcileOrphanedPrRemotesSsh(
provider: SshGitProvider,
repoPath: string,
repoId: string,
store: WorktreePushTargetStore
): Promise<void> {
if (!shouldReconcileNow(repoId)) {
return
}
lastReconciledAtByRepoId.set(repoId, Date.now())
try {
const liveWorktrees = await provider.listWorktrees(repoPath)
logReclaimed(
repoPath,
await reconcileOrphanedPrRemotesWithExec(
repoPath,
repoId,
store,
(args, cwd) => provider.exec(args, cwd),
liveWorktrees.map((worktree) => worktree.path)
)
)
} catch (error) {
console.warn(`[worktrees] Failed to reconcile orphaned PR remotes (SSH) for ${repoPath}`, error)
}
}
+155 -119
View File
@@ -104,6 +104,10 @@ import {
type GitRemoteExec,
type WorktreePushTargetStore
} from './worktree-push-target-cleanup'
import {
reconcileOrphanedPrRemotes,
reconcileOrphanedPrRemotesSsh
} from './worktree-push-target-reconciliation'
import {
configureCreatedWorktreePushTargetWithExec,
ensureUniqueRemoteName,
@@ -1030,6 +1034,18 @@ export async function cleanupUnusedWorktreePushTargetRemote(
} catch (error) {
console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error)
}
// Why: also catches remotes this specific removal couldn't reclaim (legacy metadata,
// a preserved branch since deleted, a worktree removed outside Orca) -- see
// worktree-push-target-reconciliation.ts. Rate-limited internally; safe to call every removal.
// Not awaited: a repo with a large backlog (the scenario this exists for) can have dozens of
// candidate remotes, each probed with a couple of git subprocesses -- that must never add
// latency to the worktree-removal call the user is waiting on. It catches its own errors.
void reconcileOrphanedPrRemotes(
repoPath,
getRepoIdFromWorktreeId(removedWorktreeId),
store,
gitOptions
)
}
export async function configureCreatedWorktreePushTarget(
@@ -1134,6 +1150,14 @@ export async function cleanupUnusedWorktreePushTargetRemoteSsh(
error
)
}
// Why: SSH counterpart of the sweep above -- the execution host owns these remotes.
// Not awaited for the same reason as the local path: never add sweep latency to removal.
void reconcileOrphanedPrRemotesSsh(
provider,
repoPath,
getRepoIdFromWorktreeId(removedWorktreeId),
store
)
}
async function readRemoteEffectiveHooks(
@@ -2165,130 +2189,139 @@ export async function createLocalWorktree(
let lastExistingReviewNumber: number | null = null
const shouldRetireGeneratedName =
args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName)
const retiredNameRegistry = shouldRetireGeneratedName
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
: null
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = shouldRetireGeneratedName
? getGeneratedWorktreeCreateCandidate(
sanitizedName,
suffix,
retiredNameRegistry?.exhaustedTiers
)
: getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = shouldRetireGeneratedName
? effectiveSanitizedName
: requestedName.trim()
? getWorktreeCreateCandidate(requestedName, suffix)
: effectiveSanitizedName
if (isRetiredName?.(effectiveSanitizedName)) {
continue
}
attempts += 1
lastExistingReviewNumber = null
await timing.time('resolve_name', async () => {
const retiredNameRegistry = shouldRetireGeneratedName
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
: null
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
for (
let suffix = 1, attempts = 0;
attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS;
suffix += 1
) {
effectiveSanitizedName = shouldRetireGeneratedName
? getGeneratedWorktreeCreateCandidate(
sanitizedName,
suffix,
retiredNameRegistry?.exhaustedTiers
)
: getWorktreeCreateCandidate(sanitizedName, suffix)
effectiveRequestedName = shouldRetireGeneratedName
? effectiveSanitizedName
: requestedName.trim()
? getWorktreeCreateCandidate(requestedName, suffix)
: effectiveSanitizedName
if (isRetiredName?.(effectiveSanitizedName)) {
continue
}
attempts += 1
lastExistingReviewNumber = null
branchName = await resolveCreateBranchName(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: getBranchNameOverrideCandidate(args.branchNameOverride, suffix),
effectiveSanitizedName,
settings,
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
const selectedReview = getSelectedReviewBranch(args)
if (selectedReview?.provider === 'github') {
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
lastExistingReviewNumber = lastExistingPR.number
}
} else if (selectedReview) {
let hostedReview: Awaited<ReturnType<typeof getSelectedHostedReviewForBranch>> = null
try {
hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args)
} catch {
lookupFailed = true
}
if (!lookupFailed && hostedReview?.matchesSelected) {
lastBranchConflictKind = null
} else if (hostedReview) {
lastExistingReviewNumber = hostedReview.number
branchName = await resolveCreateBranchName(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: getBranchNameOverrideCandidate(args.branchNameOverride, suffix),
effectiveSanitizedName,
settings,
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
const selectedReview = getSelectedReviewBranch(args)
if (selectedReview?.provider === 'github') {
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
lastExistingReviewNumber = lastExistingPR.number
}
} else if (selectedReview) {
let hostedReview: Awaited<ReturnType<typeof getSelectedHostedReviewForBranch>> = null
try {
hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args)
} catch {
lookupFailed = true
}
if (!lookupFailed && hostedReview?.matchesSelected) {
lastBranchConflictKind = null
} else if (hostedReview) {
lastExistingReviewNumber = hostedReview.number
}
}
}
}
}
if (lastBranchConflictKind) {
continue
}
// Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it.
if (suffix > 1 && !checkoutExistingBranch) {
lastExistingPR = null
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastExistingReviewNumber = lastExistingPR.number
if (lastBranchConflictKind) {
continue
}
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
// Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it.
if (suffix > 1 && !checkoutExistingBranch) {
lastExistingPR = null
try {
lastExistingPR = await getLocalGitHubPrForBranch(
repo.path,
branchName,
localWorktreeGitOptions
)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastExistingReviewNumber = lastExistingPR.number
continue
}
}
resolved = true
break
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
resolved = true
break
}
})
if (!resolved) {
// Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner.
if (lastExistingReviewNumber !== null) {
// Read once and format eagerly: the suffix loop assigns this from a callback, so the `let`'s
// narrowing does not reach the message.
const existingReviewNumber = lastExistingReviewNumber
if (existingReviewNumber !== null) {
throw new Error(
`Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.`
`Branch "${branchName}" already has PR #${String(existingReviewNumber)}. Pick a different ${branchConflictSubject}.`
)
}
if (lastBranchConflictKind) {
@@ -2337,14 +2370,17 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId)
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
const requestedPushTarget = args.pushTarget
if (requestedPushTarget) {
// Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry.
preparedPushTarget = await prepareWorktreePushTarget(
repo.path,
args.pushTarget,
store,
repo.id,
localWorktreeGitOptions
preparedPushTarget = await timing.time('prepare_push_target', () =>
prepareWorktreePushTarget(
repo.path,
requestedPushTarget,
store,
repo.id,
localWorktreeGitOptions
)
)
}
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
toSshExecutionHostId
} from '../../shared/execution-host'
import { getSshProviderAuthority } from '../ssh/ssh-provider-authority'
import {
listWorktreesMock,
@@ -443,7 +447,76 @@ describe('registerWorktreeHandlers', () => {
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => {
// Runtime-host rows are exempt from gcStaleWorktreeMeta exactly as SSH ones are, so a paired
// client needs this path to ever drop them (#17776).
it('retires runtime-host metadata an authoritative scan proved gone', async () => {
const runtimeHostId = toRuntimeExecutionHostId('env-1')
const runtimeRepo = {
id: 'repo-1',
path: '/home/orca/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: runtimeHostId
}
const metaById: Record<string, ReturnType<typeof makeWorktreeMeta>> = {
'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }),
'repo-1::/home/orca/other-host': makeWorktreeMeta({
hostId: toSshExecutionHostId('target-a')
})
}
store.getRepos.mockReturnValue([runtimeRepo])
store.getProjectHostSetups.mockReturnValue([])
store.getAllWorktreeMeta.mockReturnValue(metaById)
store.removeWorktreeMeta.mockImplementation((worktreeId: string) => {
delete metaById[worktreeId]
})
const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
repoId: runtimeRepo.id,
executionHostId: runtimeHostId,
worktreeIds: ['repo-1::/home/orca/deleted', 'repo-1::/home/orca/other-host']
})
// The row stamped to another host needs that host's own scan, not this one's.
expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] })
expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith(
'repo-1::/home/orca/deleted',
runtimeHostId
)
})
// A repo that reaches its checkouts over SSH is not the runtime host's to condemn. The refusal
// comes from `findExactRepoOwner`: a runtime `executionHostId` beside a `connectionId` is
// contradictory ownership evidence, so no owner resolves at all.
it('refuses to retire a connection-backed repo under a runtime host id', async () => {
const runtimeHostId = toRuntimeExecutionHostId('env-1')
store.getRepos.mockReturnValue([
{
id: 'repo-1',
path: '/home/orca/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
executionHostId: runtimeHostId,
connectionId: 'target-a'
}
])
store.getAllWorktreeMeta.mockReturnValue({
'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId })
})
expect(
await handlers['worktrees:forgetRemovedForExecutionHost'](null, {
repoId: 'repo-1',
executionHostId: runtimeHostId,
worktreeIds: ['repo-1::/home/orca/deleted']
})
).toEqual({ forgottenWorktreeIds: [] })
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('refuses to retire metadata for non-executing hosts and unowned repos', async () => {
const sshRepo = {
id: 'repo-1',
path: '/remote/repo-a',
@@ -4,7 +4,10 @@ import type {
CreateWorktreeResult,
AdoptProvisionedRootArgs
} from '../../../../shared/worktree/create-types'
import { withWorktreeSpan } from '../../../observability/instrumentation'
import {
addWorktreeCreatePhaseAttributes,
withWorktreeSpan
} from '../../../observability/instrumentation'
import { workspaceSourceSchema } from '../../../../shared/telemetry-events'
import type { WorkspaceSource } from '../../../../shared/telemetry-events'
import {
@@ -36,7 +39,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
async (_event, rawArgs: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
const args = normalizeLinkedWorkItemFields(rawArgs)
// Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator.
return withWorktreeSpan({ stage: 'create' }, async () => {
return withWorktreeSpan({ stage: 'create' }, async (span) => {
const repo = store.getRepo(args.repoId)
if (!repo) {
throw new Error(`Repo not found: ${args.repoId}`)
@@ -74,6 +77,9 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
throw error
}
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
if (result.timing) {
addWorktreeCreatePhaseAttributes(span, result.timing)
}
// Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name.
track('workspace_created', {
@@ -103,11 +103,21 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void {
const requestedExecutionHostId = args?.executionHostId ?? 'ssh:'
const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : []
const parsedHost = parseExecutionHostId(requestedExecutionHostId)
if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) {
// Runtime hosts belong here for the same reason SSH ones do: their rows are exempt from
// gcStaleWorktreeMeta, so a scan-proven removal is the only thing that ever retires them.
if (
(parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime') ||
worktreeIds.length === 0
) {
return nothingForgotten
}
// No runtime arm in the check below: `findExactRepoOwner` already refuses a repo carrying both
// a runtime `executionHostId` and a `connectionId`, because `resolveRepoOwnershipEvidence`
// calls that pair contradictory and one non-owned candidate voids the whole lookup. A second
// check would be unreachable, and unreachable code on a destructive path reads as a guarantee
// it is not making.
const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId)
if (!repo || repo.connectionId !== parsedHost.targetId) {
if (!repo || (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId)) {
return nothingForgotten
}
// Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips
@@ -3,6 +3,7 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer'
import {
_gitSpanSamplingBucketCountForTests,
_resetGitSpanSamplingForTests,
addWorktreeCreatePhaseAttributes,
withGitSpan
} from './instrumentation'
@@ -167,3 +168,50 @@ describe('withGitSpan sampling', () => {
expect(_gitSpanSamplingBucketCountForTests()).toBe(1)
})
})
describe('addWorktreeCreatePhaseAttributes', () => {
function capture(): {
attributes: Record<string, unknown>
span: Parameters<typeof addWorktreeCreatePhaseAttributes>[0]
} {
const attributes: Record<string, unknown> = {}
const span = {
setAttribute: (key: string, value: unknown) => {
attributes[key] = value
}
} as unknown as Parameters<typeof addWorktreeCreatePhaseAttributes>[0]
return { attributes, span }
}
it('counts concurrent phases once when measuring unattributed time', () => {
const { attributes, span } = capture()
// Create resolves shared directories and .worktreeinclude concurrently; summing their
// durations would claim 400ms of coverage for a 200ms window.
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 1000,
phases: [
{ phase: 'resolve_shared_directories', startedAtMs: 100, durationMs: 200 },
{ phase: 'resolve_worktreeinclude', startedAtMs: 150, durationMs: 150 }
]
})
expect(attributes['worktree.create.phase.resolve_shared_directories_ms']).toBe(200)
expect(attributes['worktree.create.phase.resolve_worktreeinclude_ms']).toBe(150)
// Covered wall clock is 100..300, so 800ms is genuinely unaccounted for.
expect(attributes['worktree.create.unattributed_ms']).toBe(800)
})
it('sums disjoint phases and never reports negative unattributed time', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 500,
phases: [
{ phase: 'resolve_name', startedAtMs: 0, durationMs: 100 },
{ phase: 'git_worktree_add', startedAtMs: 300, durationMs: 200 }
]
})
expect(attributes['worktree.create.total_ms']).toBe(500)
expect(attributes['worktree.create.unattributed_ms']).toBe(200)
})
})
+56 -3
View File
@@ -202,10 +202,11 @@ export type WorktreeSpanArgs = {
readonly path?: string
}
/** Wrap a worktree-setup phase in a `worktree.<stage>` span. */
/** Wrap a worktree-setup phase in a `worktree.<stage>` span. The callback receives the span so a
* create can attach its own phase breakdown; the git children alone leave the waits invisible. */
export async function withWorktreeSpan<T>(
meta: WorktreeSpanArgs,
fn: () => Promise<T>
fn: (span: ActiveSpan) => Promise<T>
): Promise<T> {
return withSpan(
`worktree.${meta.stage}`,
@@ -214,12 +215,64 @@ export async function withWorktreeSpan<T>(
if (meta.path) {
span.setAttribute('worktree.path', meta.path)
}
return await fn()
return await fn(span)
},
{ attributes: { kind: 'worktree' } }
)
}
type WorktreeCreatePhaseTiming = {
readonly phase: string
readonly startedAtMs: number
readonly durationMs: number
}
/** Wall-clock span covered by at least one phase. Create runs some phases concurrently, so summing
* durations double-counts and would report overlap as coverage the phases never had. */
function measuredWallClockMs(phases: readonly WorktreePhaseInterval[]): number {
const intervals = [...phases]
.map((phase) => [phase.startedAtMs, phase.startedAtMs + phase.durationMs] as const)
.sort((left, right) => left[0] - right[0])
let covered = 0
let openedAt: number | null = null
let closesAt = 0
for (const [start, end] of intervals) {
if (openedAt === null) {
openedAt = start
closesAt = end
continue
}
if (start <= closesAt) {
closesAt = Math.max(closesAt, end)
continue
}
covered += closesAt - openedAt
openedAt = start
closesAt = end
}
return openedAt === null ? 0 : covered + (closesAt - openedAt)
}
type WorktreePhaseInterval = Pick<WorktreeCreatePhaseTiming, 'startedAtMs' | 'durationMs'>
/** Records a create's phase breakdown on its span. Phase names are already a closed vocabulary in
* the recorder, so they are safe to key on; nothing here carries a branch name or a path. */
export function addWorktreeCreatePhaseAttributes(
span: ActiveSpan,
timing: { totalDurationMs: number; phases: readonly WorktreeCreatePhaseTiming[] }
): void {
span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs))
for (const phase of timing.phases) {
span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs))
}
// What the phases do not cover is the number that matters when create feels slow for no visible
// reason, so name it rather than leaving it to subtraction.
span.setAttribute(
'worktree.create.unattributed_ms',
Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases)))
)
}
/** Closed set so a typo can't silently mint an orphan span name. */
export type WorktreeRemoveStage =
| 'archive_hook'
@@ -66,6 +66,24 @@ export function rekeyOwnerKey(
return null
}
/**
* Every worktree locator an owner key could name.
*
* Two readings, because one key can be both: with a repo literally named `worktree`,
* `worktree::/p` is a `<repoId>::<path>` locator AND parses as a `worktree:` workspace key naming
* repo `` (empty). `ownerKeyBelongsToRepo` accepts either, so a caller that reasons about a key
* without a repo id in hand has to consider both or it will disagree with the predicate.
*/
export function ownerKeyWorktreeIds(ownerKey: string): string[] {
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
: ownerKey
const scope = parseWorkspaceKey(ownerKey)
return scope?.type === 'worktree' && scope.worktreeId !== rawOwnerKey
? [rawOwnerKey, scope.worktreeId]
: [rawOwnerKey]
}
export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean {
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
@@ -397,6 +397,8 @@ describe('Store.migrateWorktreeIdentity', () => {
it('moves persisted mobile selections across reloads', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo1', path: '/repo1' }))
store.setMobileClientTabSelections({
'device-a': {
[OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
@@ -10,6 +10,7 @@ import {
createStore,
writeDataFile,
readDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
@@ -53,6 +54,11 @@ describe('cross-host pane identity migration', () => {
it('refuses hostless alias and acknowledgement rewrites for a tab id two partitions share', async () => {
writeDataFile({
schemaVersion: 1,
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
repos: [
makeRepo({ id: 'repo-local', path: '/repo-local' }),
makeRepo({ id: 'repo-a', path: '/repo-a' })
],
workspaceSession: makeLegacyPaneSession('repo-local', 'local-pty'),
workspaceSessionsByHostId: {
'ssh:host-a': makeLegacyPaneSession('repo-a', 'pty-a')
@@ -0,0 +1,240 @@
// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could
// reach them -- the missing-directory prune is gated on the repo still being registered, and a
// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the
// owning host's removal never reached it (#17776).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { getDefaultWorkspaceSession } from '../shared/constants'
import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope'
import type { PersistedState } from '../shared/persisted-state-types'
import {
testState,
createStore,
writeDataFile,
readDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
const LIVE_REPO = 'live-repo'
const GONE_REPO = 'gone-repo'
const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live`
const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan`
const RUNTIME_HOST = 'runtime:env-a'
const sleepingAgentFor = (worktreeId: string, tabId = 'tab-1') => ({
[`${tabId}:leaf-1`]: {
paneKey: `${tabId}:leaf-1`,
tabId,
worktreeId,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'sess-1' },
prompt: 'sleeping',
state: 'waiting' as const,
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep' as const
}
})
const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[worktreeId]: [makeTerminalTab({ id: tabId, worktreeId })]
},
activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const },
lastVisitedAtByWorktreeId: { [worktreeId]: 123 },
// The residue `profile-project-session-field-disposition` flags as leaking on repo removal.
sleepingAgentSessionsByPaneKey: sleepingAgentFor(worktreeId, tabId)
})
describe('deregistered repo residue', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('drops metadata, identity rows and sessions owned by an unregistered repo id', async () => {
const seed = await createStore()
seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' }))
seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' }))
seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' })
seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' })
seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local')
seed.flush()
// Deregister by hand: the point is that a row can outlive its repo however that happened.
const persisted = readDataFile() as PersistedState
persisted.repos = persisted.repos.filter((repo) => repo.id !== GONE_REPO)
writeDataFile(persisted)
const reloaded = await createStore()
reloaded.flush()
const swept = readDataFile() as PersistedState
expect(Object.keys(swept.worktreeMeta)).toEqual([LIVE_WORKTREE])
expect(swept.worktreeIdentityAliases).not.toHaveProperty(
composeWorktreeHostIdentity('local', GONE_WORKTREE)
)
expect(Object.keys(swept.worktreeMetaByIdentity ?? {})).toHaveLength(1)
const session = swept.workspaceSession
expect(session.tabsByWorktree).toEqual({})
expect(session.lastVisitedAtByWorktreeId).toEqual({})
expect(session.activeTabTypeByWorktree).toEqual({})
expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
})
it("sweeps a remote host's session partition the owning host's removal can never reach", async () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
workspaceSessionsByHostId: {
[RUNTIME_HOST]: sessionFor(GONE_WORKTREE)
}
})
const store = await createStore()
store.flush()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.tabsByWorktree).toEqual({})
expect(partition.activeTabTypeByWorktree).toEqual({})
})
it('keeps rows for every registered repo, on any execution host', async () => {
const remoteWorktree = `${LIVE_REPO}::/home/user/remote`
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/home/user/live', executionHostId: RUNTIME_HOST })],
worktreeMeta: { [remoteWorktree]: { hostId: RUNTIME_HOST, status: 'active' } },
workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionFor(remoteWorktree) }
})
const store = await createStore()
expect(store.getWorktreeMeta(remoteWorktree)).toBeDefined()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.tabsByWorktree[remoteWorktree]).toHaveLength(1)
// Also proves the sleeping-agent fixture is well-formed, so the sweep assertions above bite.
expect(Object.keys(partition.sleepingAgentSessionsByPaneKey ?? {})).toHaveLength(1)
})
it('leaves folder-workspace session rows alone: their keys name no repo', async () => {
const workspaceKey = folderWorkspaceKey('folder-1')
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
workspaceSession: {
...getDefaultWorkspaceSession(),
lastVisitedAtByWorktreeId: { [workspaceKey]: 7 }
}
})
const store = await createStore()
expect(store.getWorkspaceSession('local').lastVisitedAtByWorktreeId).toEqual({
[workspaceKey]: 7
})
})
// Regression: the pane-keyed records are pruned by the worktreeId they name, not by their own key,
// so an orphan whose ONLY residue is a sleeping agent survived -- and re-seeded the sweep on every
// launch, so the store never self-cleared and every load scheduled another save.
it("drops a sleeping agent that is the orphan repo's only residue, and self-clears", async () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
workspaceSession: {
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: sleepingAgentFor(GONE_WORKTREE)
}
})
const store = await createStore()
store.flush()
expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
// On disk, not just in memory: if the flush had not persisted the cleanup, the next load would
// silently redo it and the self-clearing assertion below would pass without meaning anything.
const persisted = readDataFile() as PersistedState
expect(persisted.workspaceSession.sleepingAgentSessionsByPaneKey ?? {}).toEqual({})
// Self-clearing: with the residue gone nothing re-seeds the orphan id, so the next launch has
// no work. Before the fix this stayed non-empty forever and every load scheduled another save.
const reloaded = await createStore()
expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
})
// The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches
// them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck.
it.each([
{ label: 'activeWorktreeId', session: { activeWorktreeId: GONE_WORKTREE } },
// Canonical `worktree:<id>` form, which needs unwrapping before the repo id is visible.
{
label: 'activeWorkspaceKey',
session: { activeWorkspaceKey: worktreeWorkspaceKey(GONE_WORKTREE) }
},
{
label: 'activeWorktreeIdsOnShutdown',
session: { activeWorktreeIdsOnShutdown: [GONE_WORKTREE] }
}
])("clears $label when it is the orphan repo's only residue", async ({ session }) => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })],
worktreeMeta: {},
workspaceSessionsByHostId: {
[RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session }
}
})
const store = await createStore()
store.flush()
const partition = store.getWorkspaceSession(RUNTIME_HOST)
expect(partition.activeWorktreeId ?? null).toBeNull()
expect(partition.activeWorkspaceKey ?? null).toBeNull()
expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([])
const reloaded = await createStore()
expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([])
})
// Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes.
it('leaves a profile with no orphans byte-identical across reloads', async () => {
const seed = await createStore()
seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' }))
seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' })
seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local')
seed.flush()
const canonicalizing = await createStore()
canonicalizing.flush()
const canonical = JSON.stringify(readDataFile())
const reloaded = await createStore()
reloaded.flush()
expect(JSON.stringify(readDataFile())).toBe(canonical)
})
})
@@ -123,6 +123,9 @@ describe('Store host-partitioned workspace sessions', () => {
}
})
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
const makeRepos = (...repoIds: string[]) => repoIds.map((id) => makeRepo({ id, path: `/${id}` }))
it('migrates a legacy workspaceSession blob into the local partition', async () => {
writeDataFile({
schemaVersion: 1,
@@ -194,6 +197,7 @@ describe('Store host-partitioned workspace sessions', () => {
writeDataFile({
schemaVersion: 1,
workspaceSession: makeHostSession('local-repo'),
repos: makeRepos('repo-ssh'),
workspaceSessionsByHostId: {
'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty')
},
@@ -224,6 +228,7 @@ describe('Store host-partitioned workspace sessions', () => {
writeDataFile({
schemaVersion: 1,
workspaceSession: makeHostSession('local-repo'),
repos: makeRepos('repo-a', 'repo-b'),
workspaceSessionsByHostId: {
'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'),
'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b')
@@ -488,6 +493,7 @@ describe('Store host-partitioned workspace sessions', () => {
it('removes one orphaned worktree with a host-scoped topology fence', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'repo-gone', path: '/repo-gone' }))
const worktreeId = 'repo-gone::/workspace/stale'
const session = {
...makeHostSession('repo-gone'),
@@ -728,6 +734,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSessionsByHostId: {
'runtime:good': makeHostSession('good-repo'),
// activeRepoId must be string|null; a number fails the zod parse.
@@ -753,6 +760,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSession: {
...makeHostSession('local-repo'),
// A projected/truncated write can leave a top-level field the wrong type;
@@ -813,6 +821,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSession: {
...makeHostSession('local-repo'),
tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] }
@@ -845,6 +854,7 @@ describe('Store host-partitioned workspace sessions', () => {
const worktreeId = 'repo-1::/worktree'
const profile = await canonicalize({
schemaVersion: 1,
repos: makeRepos('repo-1'),
workspaceSessionsByHostId: {
'runtime:env-a': {
...makeHostSession('runtime-repo'),
@@ -150,6 +150,8 @@ describe('Store', () => {
it('does not restore a terminal tab after its durable close flush returns', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' }))
const worktreeId = 'repo-1::/tmp/worktree-1'
const tabId = 'terminal-1'
const session: WorkspaceSessionState = {
@@ -58,7 +58,7 @@ describe('Store native-chat tab viewMode persistence', () => {
const WORKTREE = 'repo1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {},
+5 -1
View File
@@ -737,7 +737,10 @@ describe('Store', () => {
it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => {
const store = await createStore()
// A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only.
// A meta on the old SSH host with no repo row for that host — the re-point must still be
// persisted, not memory-only. The repo id stays registered so the load-time orphan sweep,
// which only reads repo ids, leaves the row alone.
store.addRepo(makeRepo({ id: 'r1', path: '/r1' }))
store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' })
const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new')
@@ -787,6 +790,7 @@ describe('Store', () => {
it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'r1', path: '/r1' }))
store.setWorkspaceSession(
{
activeRepoId: null,
+1 -1
View File
@@ -708,7 +708,7 @@ describe('Store', () => {
}
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {
'repo1::/worktree-a': { status: 'active' },
'repo1::/worktree-b': { status: 'active' }
@@ -346,7 +346,7 @@ describe('Store', () => {
const acknowledgedAt = 1_700_000_000_000
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {
@@ -408,7 +408,7 @@ describe('Store', () => {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {
@@ -166,6 +166,8 @@ describe('Store', () => {
describe('mobileClientTabSelectionsByDeviceId', () => {
it('persists device tab selections across reloads and drops malformed payloads', async () => {
const store = await createStore()
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' }))
store.setMobileClientTabSelections({
'device-a': {
'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }
@@ -188,6 +190,7 @@ describe('Store', () => {
it('prunes selections for a removed repo worktree', async () => {
const store = await createStore()
store.addRepo(makeRepo())
store.addRepo(makeRepo({ id: 'other-repo', path: '/other-repo' }))
store.setMobileClientTabSelections({
'device-a': {
'r1::/tmp/wt': {
@@ -12,10 +12,13 @@ import {
import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility'
import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations'
import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning'
import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue'
import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration'
import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations'
import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update'
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
@@ -129,6 +132,33 @@ export class RepoLifecycleOperations {
scheduleSave(this[repoLifecycleOperationsContext].scheduling)
}
/**
* Drop every persisted row owned by a repo id that is no longer registered.
*
* Runs at load because no removal path can: `removeProject` only fires while the repo is still in
* `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client
* never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner
* that could object, so this ignores the session-ownership and local-execution-host gates the
* missing-directory sweeper needs.
*/
sweepDeregisteredRepoResidue(): string[] {
const state = this[repoLifecycleOperationsContext].runtime.state
const orphanRepoIds = collectDeregisteredRepoIds(state)
if (orphanRepoIds.size === 0) {
return []
}
for (const repoId of orphanRepoIds) {
pruneWorktreeStateForRepo(this, repoId, null)
state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId)
state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions(
state.workspaceSessionsByHostId,
repoId
)
}
pruneDeregisteredRepoUiResidue(state.ui, orphanRepoIds)
return [...orphanRepoIds]
}
updateRepo(
id: string,
updates: Partial<
@@ -212,6 +242,26 @@ export function pruneMobileClientTabSelections(
}
}
function pruneDeregisteredRepoUiResidue(
ui: PersistedState['ui'],
orphanRepoIds: ReadonlySet<string>
): void {
const isOrphanWorktree = (worktreeId: string): boolean =>
orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId))
if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) {
ui.lastActiveRepoId = null
}
if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) {
ui.lastActiveWorktreeId = null
}
ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? []
for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) {
if (isOrphanWorktree(worktreeId)) {
delete ui.showDotfilesByWorktree?.[worktreeId]
}
}
}
export function getRepoUpdateOperations(
owner: RepoLifecycleOperations
): RepoUpdatePersistenceOperations {
+9 -1
View File
@@ -64,6 +64,9 @@ export class Store {
)
const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups()
this.domains.adaptation.hydrateFolderWorkspaceDiffComments()
// Load is the only place an orphaned repo id can be swept: every removal path needs the repo to
// still be registered, so rows outlive their owner without one (#17776).
const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue()
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
}
@@ -78,7 +81,12 @@ export class Store {
this.state.legacyPaneKeyAliasEntries = entries
scheduleSave(this.domains.scheduling)
})
if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) {
if (
normalized.changed ||
this.runtime.loadNeedsSave ||
adaptedProjectGroups ||
sweptRepoIds.length > 0
) {
scheduleSave(this.domains.scheduling)
}
}
@@ -0,0 +1,108 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity'
import { splitWorktreeId } from '../../../shared/worktree/id'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition'
import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity'
/**
* Repo ids that still own persisted rows but no longer appear in `state.repos`.
*
* Why nothing else finds them: every other sweeper is gated on the repo still being registered, so
* deregistering a project stranded the rows it owned permanently — including a paired client's
* mirror of a remote host's session partition, which no local repo removal can reach (#17776).
*/
export function collectDeregisteredRepoIds(state: PersistedState): Set<string> {
const liveRepoIds = new Set(state.repos.map((repo) => repo.id))
const orphanRepoIds = new Set<string>()
// Only a full `<repoId>::<path>` locator seeds the set. A bare key -- a folder workspace id, a
// repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and
// guessing wrong here deletes live session state.
const addWorktreeId = (worktreeId: string | null | undefined): void => {
const repoId = worktreeId ? splitWorktreeId(worktreeId)?.repoId : undefined
if (repoId && !liveRepoIds.has(repoId)) {
orphanRepoIds.add(repoId)
}
}
/**
* Seed from an owner key, which can read as two different locators (see `ownerKeyWorktreeIds`).
* All or nothing: if either reading names a live repo the key is that repo's, and seeding the
* other reading would hand the removal pass -- which accepts either -- a live row to delete.
*/
const addOwnerKey = (ownerKey: string): void => {
const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => {
const repoId = splitWorktreeId(worktreeId)?.repoId
return repoId ? [repoId] : []
})
if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) {
for (const repoId of repoIds) {
orphanRepoIds.add(repoId)
}
}
}
// Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are
// bounded, and dropping a retired-name row would let a re-added repo reissue a name onto a cwd
// that still holds a prior occupant's agent state.
for (const worktreeId of Object.keys(state.worktreeMeta)) {
addWorktreeId(worktreeId)
}
for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) {
addWorktreeId(getWorktreeIdFromHostIdentity(alias))
}
for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) {
addWorktreeId(childId)
addWorktreeId(lineage.parentWorktreeId)
}
for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
addOwnerKey(childKey)
addOwnerKey(lineage.parentWorkspaceKey)
}
for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) {
for (const worktreeId of Object.keys(selections)) {
addWorktreeId(worktreeId)
}
}
const sessions: (WorkspaceSessionState | undefined)[] = [
state.workspaceSession,
...Object.values(state.workspaceSessionsByHostId ?? {})
]
for (const session of sessions) {
if (!session) {
continue
}
for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) {
for (const ownerKey of Object.keys(
(session[field] as Record<string, unknown> | undefined) ?? {}
)) {
addOwnerKey(ownerKey)
}
}
for (const ownerKey of Object.keys(session.tabsByWorktree ?? {})) {
addOwnerKey(ownerKey)
}
for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) {
addOwnerKey(ownerKey)
}
// Pruned by bespoke rules rather than by owner key, so the loop above never reaches them.
for (const ownerKey of [
session.activeWorktreeId,
session.activeWorkspaceKey,
...(session.activeWorktreeIdsOnShutdown ?? [])
]) {
if (ownerKey) {
addOwnerKey(ownerKey)
}
}
// Not seeded from `terminalTopologyRevisionByRepoId`: its keys are bare repo ids by contract,
// and a bare key is exactly what `addWorktreeId` refuses to trust. Rows there are removed once
// any locator seeds their repo id, which every repo that ever opened a terminal has.
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
addWorktreeId(record.worktreeId)
}
for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) {
addWorktreeId(tombstone.worktreeId)
}
}
return orphanRepoIds
}
@@ -2,6 +2,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
import {
getExecutionHostIdFromWorktreeHostIdentity,
@@ -58,10 +59,26 @@ export function pruneWorktreeStateForRepo(
}
}
}
collectPrefixedKeys(Object.keys(state.worktreeMeta))
collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {}))
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
// Why the pane-keyed records contribute owner keys: they are pruned by the worktreeId they name,
// not by their own key, so a worktree with no meta and no visit row would otherwise keep its
// sleeping agents and tombstones forever -- and keep re-seeding the orphan sweep every load.
const collectScannedRecordOwners = (session: WorkspaceSessionState | undefined): void => {
collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {}))
collectPrefixedKeys(
Object.values(session?.sleepingAgentSessionsByPaneKey ?? {}).map(
(record) => record.worktreeId
)
)
collectPrefixedKeys(
Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).map(
(tombstone) => tombstone.worktreeId
)
)
}
collectPrefixedKeys(Object.keys(state.worktreeMeta))
collectScannedRecordOwners(state.workspaceSession)
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
collectScannedRecordOwners(session)
}
for (const key of Object.keys(state.worktreeMeta)) {
@@ -656,9 +656,21 @@ describe('legacy coordinator takeover races', () => {
const detectionStarted = new Promise<void>((resolve) => {
signalDetectionStarted = resolve
})
let detectionCalls = 0
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation(
() =>
new Promise<boolean>((resolve) => {
new Promise<boolean>((resolve, reject) => {
detectionCalls += 1
// Why reject instead of re-arming: a second call would overwrite resolveDetection and
// strand the first promise, hanging to a timeout instead of naming what changed.
if (detectionCalls > 1) {
reject(
new Error(
`isTerminalRunningAgent was called ${detectionCalls} times; this test drives exactly one detection.`
)
)
return
}
resolveDetection = resolve
signalDetectionStarted?.()
})
@@ -0,0 +1,123 @@
// Why this file exists: the authoritative missing-metadata prune had exactly one caller,
// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept
// its own repos and their `worktreeMeta` rows grew without bound (#17776).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import type { Repo } from '../../shared/repo-types'
import { testState, createStore, makeRepo } from '../persistence-test-harness'
import type { Store } from '../persistence/loading-store/store'
import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries'
import type { RuntimeStore } from './runtime-store-contract'
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
const gitWorktree = (path: string): GitWorktreeInfo => ({
path,
branch: 'main',
head: 'abc1234',
isBare: false,
isMainWorktree: true
})
function queries(
store: Store,
repo: Repo,
worktrees: readonly GitWorktreeInfo[],
ok = true
): RuntimeManagedWorktreeQueries {
return new RuntimeManagedWorktreeQueries({
getStore: () => store as unknown as RuntimeStore,
listResolved: async () => [],
resolveRepo: async () => repo,
selectRepos: () => [repo],
scanRepo: async () => ({ ok, worktrees: [...worktrees] })
})
}
describe('runtime detected-worktree listing sweeps missing local metadata', () => {
let repoPath = ''
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-runtime-sweep-'))
repoPath = join(testState.dir, 'repo')
mkdirSync(repoPath, { recursive: true })
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
// Paired with the off-host case below: same fixture, no `connectionId`.
it('drops a metadata row whose directory is gone and the scan does not list', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
expect(store.getWorktreeMeta(missingId)).toBeDefined()
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeUndefined()
})
it('keeps a row whose directory still exists', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const livePath = join(testState.dir, 'live-worktree')
mkdirSync(livePath, { recursive: true })
const liveId = `${repo.id}::${livePath}`
store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' })
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(liveId)).toBeDefined()
})
// A non-authoritative scan is a failed listing, which is no evidence any checkout is gone.
it('keeps every row when the scan is not authoritative', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
await queries(store, repo, [], false).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeDefined()
})
// The execution host owns this verdict: this host cannot stat a checkout that lives behind an SSH
// connection, so a local miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md.
//
// Deliberately identical to the first case except for `connectionId`, and the row is stamped
// `local` so it is a real prune candidate. That pairing is the proof: the same fixture without a
// connection loses the row, so the connection is the only reason this one keeps it. Removing any
// single gate would not show that -- four independent checks derive from `connectionId` here.
it('never sweeps a repo whose git runs off-host', async () => {
const store = createStore()
const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' })
store.addRepo(repo)
const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}`
store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' })
await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo)
expect(store.getWorktreeMeta(missingId)).toBeDefined()
})
})
@@ -20,6 +20,10 @@ import {
} from '../../shared/worktree/visibility-sources'
import { mergeWorktree } from '../ipc/worktree-logic'
import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning'
import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning'
import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning'
import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import type { Store } from '../persistence'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan'
@@ -36,6 +40,31 @@ type Dependencies = {
scanRepo(repo: Repo): Promise<RuntimeWorktreeScanResult>
}
/**
* The destructive scan expectation for one repo, or undefined when this repo must not carry one.
*
* WSL-routed repos are excluded for the same reason the desktop listing excludes them: the listing
* runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove
* those aliases equivalent. A runtime that needs repair throws rather than resolving routing, which
* is likewise no basis for deleting rows.
*/
function captureLocalMetadataPruneExpectation(
store: RuntimeStore,
repo: Repo
): NativeLocalWorktreeMetadataScanExpectation | undefined {
if (typeof store.captureNativeLocalWorktreeMetadataScanExpectation !== 'function') {
return undefined
}
try {
if (getLocalProjectWorktreeGitOptions(store as unknown as Store, repo).wslDistro) {
return undefined
}
} catch {
return undefined
}
return store.captureNativeLocalWorktreeMetadataScanExpectation(repo)
}
export class RuntimeManagedWorktreeQueries {
constructor(private readonly deps: Dependencies) {}
@@ -129,6 +158,10 @@ export class RuntimeManagedWorktreeQueries {
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
}
}
// Why capture before the scan: listing can mutate metadata synchronously before its first
// await, and the prune revalidates against the rows as they stood when the scan was issued.
const metadataScanGeneration = getLocalWorktreeScanGeneration(repo.id)
const metadataPruneExpectation = captureLocalMetadataPruneExpectation(store, repo)
let scan: RuntimeWorktreeScanResult
try {
scan = await this.deps.scanRepo(repo)
@@ -136,6 +169,17 @@ export class RuntimeManagedWorktreeQueries {
scan = { ok: false, worktrees: [] }
}
if (scan.ok) {
// Why the runtime sweeps too: the desktop listing that used to own this runs off `ipcMain`,
// so a headless host -- which has no renderer -- never pruned its own repos' rows (#17776).
if (metadataPruneExpectation) {
await pruneMetadataMissingFromAuthoritativeLocalScan({
store: store as unknown as Store,
repo,
gitWorktrees: scan.worktrees,
scan: metadataPruneExpectation,
scanGeneration: metadataScanGeneration
})
}
pruneLineageForMissingRepoWorktrees(store as unknown as Store, repo, scan.worktrees)
}
const matcher = createWorktreeVisibilitySourceMatcher(
@@ -30,6 +30,9 @@ export type RuntimeStore = {
removeProjectForHost?: Store['removeProjectForHost']
reorderRepos?: Store['reorderRepos']
getAllWorktreeMeta: Store['getAllWorktreeMeta']
captureNativeLocalWorktreeMetadataScanExpectation?: Store['captureNativeLocalWorktreeMetadataScanExpectation']
pruneSessionlessMissingLocalWorktreeMetadataForRepo?: Store['pruneSessionlessMissingLocalWorktreeMetadataForRepo']
getProfileStorageDirectory?: Store['getProfileStorageDirectory']
getWorktreeMeta: Store['getWorktreeMeta']
setWorktreeMeta: Store['setWorktreeMeta']
setWorktreeMetaForHost?: Store['setWorktreeMetaForHost']
@@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness'
import {
testState,
createStore,
makeRepo,
makeTerminalTab,
writeDataFile
} from './persistence-test-harness'
import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures'
import { getDefaultPersistedState } from '../shared/constants'
@@ -196,6 +202,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', ()
it('does not clear and rebind a retired surface loaded from an older profile', async () => {
const paneKey = `${TAB}:${TEST_LEAF_1}`
const persisted = getDefaultPersistedState(testState.dir)
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })]
persisted.workspaceSession = {
...persisted.workspaceSession,
...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }),
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery'
import { logStartupMilestone } from './startup-diagnostics'
import { mainProcessState as state } from './main-process-state'
import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files'
export function registerMainProcessIpcHandlers(): void {
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
@@ -36,6 +37,20 @@ export function registerMainProcessIpcHandlers(): void {
state.pendingOpenSettings.matches(event.sender.id, { consume: true })
)
ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume())
// Why: the renderer pulls this once its ui:openMarkdownFiles listener attaches, so a
// cold-start "Open With" queued before mount still opens. The pull doubles as the proof
// that the listener is live, which is what lets main start pushing.
ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => {
state.markdownFileOpenListenerReady = true
const filePaths = state.osOpenedMarkdownFiles.consume()
try {
return await resolveOpenedMarkdownDocuments(filePaths)
} catch (error) {
// Why restored: the renderer never received these, so a later mount must still get them.
state.osOpenedMarkdownFiles.restore(filePaths)
throw error
}
})
ipcMain.handle(
'app:startupDiagnostic',
(_event, event: string, details?: Record<string, unknown>) => {
+7
View File
@@ -36,6 +36,7 @@ import type { ServeOptions } from './main-process-serve'
import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker'
import { ServeReadinessPublisher } from '../server/serve-readiness'
import { SkillShareDeepLinkState } from './skill-share-deep-link-state'
import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
import {
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
@@ -90,6 +91,12 @@ export const mainProcessState = {
// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount.
pendingOpenSettings: createWebContentsTimedFlag(),
skillShareDeepLinks: new SkillShareDeepLinkState(),
// Why: a Finder/Explorer "Open With" can land before any window exists; the renderer pulls this buffer on mount.
osOpenedMarkdownFiles: new OsOpenedMarkdownFileState(),
// Why a latch and not just "a window exists": a window can be up while its renderer has not
// attached the ui:openMarkdownFiles listener yet, and a push into that gap is dropped by
// Electron with no error. Only the renderer's own pull proves the listener is live.
markdownFileOpenListenerReady: false,
firstWindowStartupServicesReady: Promise.resolve(),
managedWslCliReconciliationReady: Promise.resolve(),
managedWslCliStartupBarrierReady: Promise.resolve(),
@@ -145,6 +145,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
clearExpectedRendererReload(rendererWebContentsId)
recordCrashBreadcrumb('main_window_loaded')
logStartupMilestone('did-finish-load')
// Why cleared here: a reload drops the old ui:openMarkdownFiles listener, and the fresh
// renderer re-attaches by pulling. Pushing into the gap between would be silently lost.
state.markdownFileOpenListenerReady = false
const currentStore = state.store
if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') {
trackAppOpenedOnce()
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
/**
* The two ways a queued "Open With" can be lost between main and the renderer. Both are
* about ownership: main must not drop paths it has not proven the renderer received.
*/
describe('os-opened markdown delivery ownership', () => {
it('keeps the batch when resolution rejects on the pull path', async () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths(['/notes/a.md'])
const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable'))
// Mirrors the ipcMain.handle('ui:consumePendingMarkdownFileOpens') body.
const pull = async (): Promise<unknown> => {
const filePaths = state.consume()
try {
return await resolve(filePaths)
} catch (error) {
state.restore(filePaths)
throw error
}
}
await expect(pull()).rejects.toThrow('floating root unavailable')
// Without the restore the file would be gone and no later mount could ever open it.
expect(state.consume()).toEqual(['/notes/a.md'])
})
it('holds the batch while the renderer listener is not yet attached', () => {
const state = new OsOpenedMarkdownFileState()
const send = vi.fn()
let listenerReady = false
// Mirrors publishOsOpenedMarkdownFiles()'s guard.
const publish = (): void => {
if (!listenerReady) {
return
}
const filePaths = state.consume()
if (filePaths.length > 0) {
send(filePaths)
}
}
// A window exists, but the renderer has not mounted its bridge yet: send() here would be
// dropped by Electron with no error, and consuming would destroy the queue.
state.captureFilePaths(['/notes/a.md'], publish)
expect(send).not.toHaveBeenCalled()
// The renderer's pull is what proves the listener is live.
listenerReady = true
state.captureFilePaths(['/notes/b.md'], publish)
expect(send).toHaveBeenCalledExactlyOnceWith(['/notes/a.md', '/notes/b.md'])
expect(state.consume()).toEqual([])
})
it('restores a batch the window could no longer receive', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths(['/notes/a.md'])
const filePaths = state.consume()
// Window died between consume and send.
state.restore(filePaths)
expect(state.consume()).toEqual(['/notes/a.md'])
})
})
@@ -0,0 +1,306 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isMarkdownDocumentName } from '../ipc/markdown-documents'
import {
MAX_PENDING_OS_OPENED_MARKDOWN_FILES,
OsOpenedMarkdownFileState,
markdownPathsFromArguments,
resolveOpenedMarkdownDocuments
} from './os-opened-markdown-files'
vi.mock('../ipc/filesystem-auth', () => ({
authorizeExternalPath: vi.fn()
}))
vi.mock('../ipc/floating-workspace-directory', () => ({
ensureDefaultFloatingWorkspacePath: vi.fn()
}))
const { authorizeExternalPath } = await import('../ipc/filesystem-auth')
const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory')
describe('markdownPathsFromArguments', () => {
it('keeps absolute markdown paths and drops other extensions', () => {
expect(
markdownPathsFromArguments(
[
'/Users/dev/notes/a.md',
'/Users/dev/notes/b.markdown',
'/Users/dev/notes/c.mdx',
'/Users/dev/notes/d.txt',
'/Users/dev/src/e.tsx',
'/Users/dev/notes/README'
],
'darwin'
)
).toEqual(['/Users/dev/notes/a.md', '/Users/dev/notes/b.markdown', '/Users/dev/notes/c.mdx'])
})
it('drops switches, including Chromium-style ones that would otherwise look like values', () => {
expect(
markdownPathsFromArguments(
['--serve', '-v', '--allow-file-access-from-files', '/Users/dev/notes/a.md'],
'darwin'
)
).toEqual(['/Users/dev/notes/a.md'])
})
it('drops the executable and dev entries because none of them end in a markdown extension', () => {
const nonDocumentEntries = [
'/Applications/Orca.app/Contents/MacOS/Orca',
'/Users/dev/orca/out/main/index.js',
'/Applications/Orca.app/Contents/Resources/app.asar'
]
// The module documents that the extension check alone excludes these; hold it to that.
for (const entry of nonDocumentEntries) {
expect(isMarkdownDocumentName(entry), entry).toBe(false)
}
expect(
markdownPathsFromArguments([...nonDocumentEntries, '/Users/dev/notes/a.md'], 'darwin')
).toEqual(['/Users/dev/notes/a.md'])
})
it('drops relative paths because a second instance has no meaningful cwd', () => {
expect(
markdownPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin')
).toEqual([])
})
it('accepts win32 drive-letter and UNC paths', () => {
expect(
markdownPathsFromArguments(
['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md', 'C:\\Users\\dev\\todo.txt'],
'win32'
)
).toEqual(['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md'])
})
it('dedupes case-insensitively on win32 and keeps the first spelling', () => {
expect(markdownPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([
'C:\\notes\\A.md'
])
})
it('normalizes parent segments before deduping', () => {
expect(
markdownPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32')
).toEqual(['C:\\notes\\a.md'])
expect(markdownPathsFromArguments(['/docs/../notes/a.md', '/notes/a.md'], 'darwin')).toEqual([
'/notes/a.md'
])
})
it('does not dedupe case-insensitively on posix, where casing is a different file', () => {
expect(markdownPathsFromArguments(['/a/A.md', '/a/a.md'], 'linux')).toEqual([
'/a/A.md',
'/a/a.md'
])
})
it('accepts a file:// URI, which the desktop entry %U field code permits', () => {
// Why defensive rather than load-bearing: GLib decodes a local file:// URI to a plain
// path before spawning (measured on Ubuntu 24.04), so Linux hits the plain-path branch
// today. The %U spec still allows a URI, and a launcher that passes one literally would
// otherwise be dropped without a trace.
expect(
markdownPathsFromArguments(
['file:///home/me/notes/a.md', 'file:///home/me/notes/b.txt'],
'linux'
)
).toEqual(['/home/me/notes/a.md'])
})
it('percent-decodes a file:// URI so a path with spaces still opens', () => {
expect(markdownPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([
'/home/me/design notes.md'
])
})
it('decodes win32 file:// URIs, including UNC authority form', () => {
expect(
markdownPathsFromArguments(
['file:///C:/Users/me/todo.md', 'file://server/share/a.md'],
'win32'
)
).toEqual(['C:\\Users\\me\\todo.md', '\\\\server\\share\\a.md'])
})
it('dedupes a path delivered as both a URI and a bare path', () => {
expect(markdownPathsFromArguments(['file:///home/me/a.md', '/home/me/a.md'], 'linux')).toEqual([
'/home/me/a.md'
])
})
it('drops a malformed or non-file URL instead of throwing', () => {
expect(() =>
markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux')
).not.toThrow()
expect(
markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux')
).toEqual([])
})
it('honours the platform argument rather than the host OS', () => {
const argv = ['C:\\notes\\a.md', '/notes/b.md']
// Same argv, two platforms: a win32 path is not absolute to posix, and posix input is
// renormalized to backslashes on win32. Neither result may depend on where the suite runs.
expect(markdownPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md'])
expect(markdownPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md'])
})
})
// Why resolve(): the state uses the host platform by default, so fixture paths must already be
// spelled the way the host's path module normalizes them (`\n\a.md` and a drive on Windows).
const hostPath = (name: string): string => resolve(sep, 'notes', name)
describe('OsOpenedMarkdownFileState', () => {
it('reports no capture and does not publish when argv carries no markdown', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', '--serve'], publish)).toBe(
false
)
expect(publish).not.toHaveBeenCalled()
expect(state.consume()).toEqual([])
})
it('buffers and publishes when argv carries markdown', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
const filePath = hostPath('a.md')
expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', filePath], publish)).toBe(
true
)
expect(publish).toHaveBeenCalledTimes(1)
expect(state.consume()).toEqual([filePath])
})
it('captures a single macOS open-file path', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
const filePath = hostPath('a.md')
expect(state.captureFilePaths([filePath], publish)).toBe(true)
expect(state.captureFilePaths([hostPath('a.png')], publish)).toBe(false)
expect(publish).toHaveBeenCalledTimes(1)
expect(state.consume()).toEqual([filePath])
})
it('does not duplicate a path captured twice', () => {
const state = new OsOpenedMarkdownFileState()
const filePath = hostPath('a.md')
state.captureFilePaths([filePath])
state.captureFilePaths([filePath])
state.capture(['orca', filePath])
expect(state.consume()).toEqual([filePath])
})
it('drains the buffer on consume', () => {
const state = new OsOpenedMarkdownFileState()
const paths = [hostPath('a.md'), hostPath('b.md')]
state.captureFilePaths(paths)
expect(state.consume()).toEqual(paths)
expect(state.consume()).toEqual([])
})
it('restores an undelivered batch at the front of the buffer', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths([hostPath('later.md')])
state.restore([hostPath('undelivered.md')])
expect(state.consume()).toEqual([hostPath('undelivered.md'), hostPath('later.md')])
})
it('caps the buffer when captures overflow it', () => {
const state = new OsOpenedMarkdownFileState()
const overflow = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 5
const paths = Array.from({ length: overflow }, (_, index) => hostPath(`file-${index}.md`))
expect(state.captureFilePaths(paths)).toBe(true)
expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES))
})
it('caps the buffer when a restore overflows it', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths([hostPath('pending.md')])
const restored = Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) =>
hostPath(`restored-${index}.md`)
)
state.restore(restored)
const pending = state.consume()
expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
expect(pending).toEqual(restored)
})
})
describe('resolveOpenedMarkdownDocuments', () => {
let floatingRoot: string
let fileRoot: string
beforeEach(async () => {
vi.mocked(authorizeExternalPath).mockClear()
vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear()
floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-'))
fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-'))
vi.mocked(ensureDefaultFloatingWorkspacePath).mockResolvedValue(floatingRoot)
})
afterEach(async () => {
await rm(floatingRoot, { recursive: true, force: true })
await rm(fileRoot, { recursive: true, force: true })
})
it('resolves a real file outside the floating root to a basename-relative document', async () => {
const filePath = join(fileRoot, 'design notes.md')
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOpenedMarkdownDocuments([filePath])
expect(documents).toEqual([
{
filePath,
relativePath: 'design notes.md',
basename: 'design notes.md',
name: 'design notes'
}
])
expect(authorizeExternalPath).toHaveBeenCalledWith(filePath)
})
it('drops a directory that merely looks like a markdown file', async () => {
const bundlePath = join(fileRoot, 'bundle.md')
await mkdir(bundlePath)
const filePath = join(fileRoot, 'real.md')
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath])
expect(documents.map((document) => document.filePath)).toEqual([filePath])
// Security contract: a path we never validated must never be authorized for renderer reads.
expect(authorizeExternalPath).toHaveBeenCalledTimes(1)
expect(authorizeExternalPath).toHaveBeenCalledWith(filePath)
})
it('drops a path that no longer exists without authorizing it', async () => {
const missingPath = join(fileRoot, 'gone.md')
expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([])
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
it('returns nothing for an empty input without touching the filesystem', async () => {
expect(await resolveOpenedMarkdownDocuments([])).toEqual([])
expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled()
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,149 @@
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import { authorizeExternalPath } from '../ipc/filesystem-auth'
import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents'
// Why: a shell can only ever hand over the files the user selected; anything past this is a
// runaway argv, and buffering it unbounded would pin the paths for the whole session.
export const MAX_PENDING_OS_OPENED_MARKDOWN_FILES = 32
/**
* Resolves one argv entry to a local absolute path, or null if it is not one.
*
* Why file:// is accepted defensively: electron-builder appends the `%U` field code to the
* generated Linux `Exec=` line, and `%U` is specified as "URLs". GLib turns out to decode a
* local `file://` URI back to a plain path before spawning (measured on Ubuntu 24.04, via
* the same `launch_uris` call a file manager makes), so the branch below is not what fires
* there today — but the spec permits a URI, and a launcher that honours it literally would
* otherwise be silently dropped. macOS `open-file` and the Windows shell `%1` pass paths.
*/
function localPathFromArgument(argument: string, platform: NodeJS.Platform): string | null {
const pathApi = platform === 'win32' ? path.win32 : path.posix
if (argument.startsWith('file://')) {
try {
// Why the explicit windows flag: this must decode the same way on any host so the
// behaviour is testable, and it is what turns `file://server/share` back into a UNC path.
return fileURLToPath(argument, { windows: platform === 'win32' })
} catch {
return null
}
}
return pathApi.isAbsolute(argument) ? argument : null
}
/**
* Absolute markdown paths an OS "Open With" put on a launch or second-instance argv.
*
* Why no executable/asar/dev-entry filtering: none of those argv entries end in a markdown
* extension, so the extension check already excludes them. Relative entries are dropped
* because the shell always passes absolute paths and `cwd` is meaningless for a second instance.
*/
export function markdownPathsFromArguments(
argv: readonly string[],
platform: NodeJS.Platform = process.platform
): string[] {
const pathApi = platform === 'win32' ? path.win32 : path.posix
const seen = new Set<string>()
const paths: string[] = []
for (const rawArgument of argv) {
if (!rawArgument || rawArgument.startsWith('-')) {
continue
}
const argument = localPathFromArgument(rawArgument, platform)
if (!argument || !isMarkdownDocumentName(argument)) {
continue
}
const normalized = pathApi.normalize(argument)
// Why lowercased on win32: the shell round-trips drive letters and 8.3 casing
// inconsistently, and two spellings of one path must not open two tabs.
const key = platform === 'win32' ? normalized.toLowerCase() : normalized
if (seen.has(key)) {
continue
}
seen.add(key)
paths.push(normalized)
}
return paths
}
/**
* Buffers markdown paths the OS handed us until a renderer can receive them.
*
* Mirrors SkillShareDeepLinkState: main pushes when a window is already live, and the
* renderer pulls the same buffer when its listener attaches, so a cold-start "Open With"
* that lands before mount is not dropped.
*/
export class OsOpenedMarkdownFileState {
private pending: string[] = []
/** Returns true when argv carried at least one markdown path. */
capture(argv: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(argv), publish)
}
/** Returns true when at least one path was a markdown document. */
captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(filePaths), publish)
}
consume(): string[] {
const pending = this.pending
this.pending = []
return pending
}
/** Puts an undelivered batch back at the front so the next renderer still receives it. */
restore(filePaths: readonly string[]): void {
this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
}
private add(filePaths: readonly string[], publish?: () => void): boolean {
if (filePaths.length === 0) {
return false
}
const merged = [...this.pending]
for (const filePath of filePaths) {
if (!merged.includes(filePath)) {
merged.push(filePath)
}
}
this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
publish?.()
return true
}
}
/**
* Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own
* file picker produces, authorizing each one for the renderer's later read.
*/
export async function resolveOpenedMarkdownDocuments(
filePaths: readonly string[]
): Promise<MarkdownDocument[]> {
if (filePaths.length === 0) {
return []
}
const floatingRoot = await ensureDefaultFloatingWorkspacePath()
const documents: MarkdownDocument[] = []
for (const filePath of filePaths) {
try {
// Why: the shell can hand over a bundle directory named `*.md`, or a path already
// deleted by the time we resolve. Authorize only something that is really a file.
if (!(await stat(filePath)).isFile()) {
continue
}
} catch {
continue
}
authorizeExternalPath(filePath)
documents.push(
markdownDocumentFromFilePath(floatingRoot, filePath, {
outsideRootRelativePath: 'basename'
})
)
}
return documents
}
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const read = (relativePath: string): string =>
// Why source text: this wiring is module-scope side effects in the entry point, which no
// unit test can import without booting Electron. These guards pin the call shapes instead.
readFileSync(join(process.cwd(), relativePath), 'utf8').replaceAll('"', "'")
describe('os-opened markdown wiring', () => {
const index = read('src/main/index.ts')
const bootstrap = read('src/main/startup/main-process-ipc-bootstrap.ts')
const controller = read('src/main/startup/main-window-controller.ts')
it('captures argv before the serve-duplicate early return', () => {
const captureIndex = index.indexOf(
'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)'
)
const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {')
expect(captureIndex).toBeGreaterThanOrEqual(0)
expect(serveGuardIndex).toBeGreaterThanOrEqual(0)
// A duplicate `orca serve` returns early; capturing after that would drop the user's files.
expect(captureIndex).toBeLessThan(serveGuardIndex)
})
it('claims the macOS open-file event so the default handler does not win it', () => {
const handlerIndex = index.indexOf("app.on('open-file'")
expect(handlerIndex).toBeGreaterThanOrEqual(0)
const preventDefaultIndex = index.indexOf('event.preventDefault()', handlerIndex)
const nextRegistrationIndex = index.indexOf('app.on(', handlerIndex + 1)
expect(preventDefaultIndex).toBeGreaterThan(handlerIndex)
if (nextRegistrationIndex !== -1) {
expect(preventDefaultIndex).toBeLessThan(nextRegistrationIndex)
}
})
it('captures the cold-start argv and lets the renderer pull it after mount', () => {
expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)')
expect(bootstrap).toContain("ipcMain.handle('ui:consumePendingMarkdownFileOpens'")
})
// Why: `webContents.send` to a renderer that has not attached the listener is dropped with no
// error, so publishing on "a window exists" alone would consume the queue into a void.
it('only pushes once the renderer has proven its listener is attached', () => {
expect(index).toContain('!state.markdownFileOpenListenerReady')
expect(bootstrap).toContain('state.markdownFileOpenListenerReady = true')
// A reload drops the listener; the fresh renderer re-proves itself by pulling again.
expect(controller).toContain('state.markdownFileOpenListenerReady = false')
})
it('restores an undelivered batch on both the push and the pull path', () => {
expect(index).toContain('state.osOpenedMarkdownFiles.restore(filePaths)')
expect(bootstrap).toContain('state.osOpenedMarkdownFiles.restore(filePaths)')
})
})
+65 -28
View File
@@ -105,6 +105,56 @@ function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return { ...env, ...extra }
}
/**
* What the two cases below wait on: a ~280MB clone of Electron.app, two swiftc
* helper builds, and `codesign --deep` over the result. Six seconds on an idle
* machine; the swiftc builds alone pass fifteen when this file runs inside the
* full suite and every core is taken. The generous ceiling only costs time on a
* run that is already failing.
*/
const PREPARE_TIMEOUT_MS = 90_000
/**
* Spawns the wrapper with its output retained.
*
* Why retained: the wrapper reports its own failures on stderr, and discarding
* them turned a crash in prepare into a bare "Timed out waiting for condition"
* with nothing to act on.
*/
function spawnDevWrapper(
args: string[],
env: NodeJS.ProcessEnv
): { wrapper: ChildProcess; readOutput: () => string } {
const wrapper = spawn(process.execPath, args, {
cwd: resolve('.'),
env,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
const collect = (chunk: Buffer): void => {
output += chunk.toString()
}
wrapper.stdout?.on('data', collect)
wrapper.stderr?.on('data', collect)
return { wrapper, readOutput: () => output }
}
async function waitForEnvFile(envFile: string, readOutput: () => string): Promise<void> {
try {
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
}, PREPARE_TIMEOUT_MS)
} catch (error) {
throw new Error(
`${(error as Error).message}: the dev wrapper never wrote ${envFile}. Wrapper output:\n${readOutput() || '(none)'}`
)
}
}
describe('run-electron-vite-dev', () => {
afterEach(async () => {
for (const pid of processesToCleanUp) {
@@ -351,26 +401,19 @@ describe('run-electron-vite-dev', () => {
async function runWrapper(runId: string): Promise<{ electronExecPath: string }> {
const pidFile = join(tempDir, `${runId}.pid`)
const envFile = join(tempDir, `${runId}.json`)
const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], {
cwd: resolve('.'),
env: {
const { wrapper, readOutput } = spawnDevWrapper(
[wrapperPath, '--remote-debugging-port=9448'],
{
...baseEnv,
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile,
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile
},
stdio: 'ignore'
})
}
)
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
}, 20000)
await waitForEnvFile(envFile, readOutput)
const trackedPids = trackPidFile(pidFile)
@@ -409,7 +452,8 @@ describe('run-electron-vite-dev', () => {
}
}
},
30000
// Two full prepares, each budgeted at PREPARE_TIMEOUT_MS.
PREPARE_TIMEOUT_MS * 2 + 30_000
)
it.skipIf(process.platform !== 'darwin')(
@@ -421,9 +465,9 @@ describe('run-electron-vite-dev', () => {
const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs')
const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs')
const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], {
cwd: resolve('.'),
env: devWrapperTestEnv({
const { wrapper, readOutput } = spawnDevWrapper(
[wrapperPath, '--remote-debugging-port=9448'],
devWrapperTestEnv({
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
@@ -431,20 +475,13 @@ describe('run-electron-vite-dev', () => {
ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile,
ORCA_DEV_BRANCH: 'feature/framework-symlinks',
ORCA_DEV_WORKTREE_NAME: 'symlink-ui'
}),
stdio: 'ignore'
})
})
)
expect(wrapper.pid).toBeTypeOf('number')
processesToCleanUp.add(wrapper.pid!)
await waitFor(() => {
try {
return readFileSync(envFile, 'utf8').trim().length > 0
} catch {
return false
}
}, 20000)
await waitForEnvFile(envFile, readOutput)
const trackedPids = trackPidFile(pidFile)
@@ -464,6 +501,6 @@ describe('run-electron-vite-dev', () => {
await stopWrapperAndTrackedPids(wrapper, trackedPids)
},
30000
PREPARE_TIMEOUT_MS + 30_000
)
})
@@ -0,0 +1,21 @@
import { setBoundedMapEntry } from './runtime/runtime-async-boundaries'
/** Two creates this close together mean more are likely; an isolated create earns no replacement. */
export const WORKTREE_CREATE_BURST_MS = 5 * 60_000
const WORKTREE_CREATE_PREPARATION_CONSUME_MAX = 64
/** When each preparation key was last consumed, so a burst can be told from an isolated create. */
const lastConsumedAt = new Map<string, number>()
/** Records this consume and reports whether it continues a burst. A replacement checkout costs a
* full tree and holds disk until its TTL, so only a user who is already creating repeatedly earns
* one; the first create of a session pays nothing for a spare nobody claims. */
export function recordPreparationConsume(key: string, now = Date.now()): boolean {
const previous = lastConsumedAt.get(key)
setBoundedMapEntry(lastConsumedAt, key, now, WORKTREE_CREATE_PREPARATION_CONSUME_MAX)
return previous !== undefined && now - previous <= WORKTREE_CREATE_BURST_MS
}
export function resetPreparationConsumeHistoryForTests(): void {
lastConsumedAt.clear()
}
@@ -0,0 +1,79 @@
import {
isWorktreeCreatePreparation,
parseWorktreePreparationOwnerPid,
parseWorktreePreparationPathOwnerPid
} from '../shared/worktree/create-preparation'
import type { AddWorktreeOptions } from './git/worktree'
import { listWorktreeGraph } from './git/worktree'
import { discardPreparedWorktree, unlockPreparedWorktree } from './git/worktree-create-preparation'
import { retryPendingPreparationDiscards } from './worktree-preparation-discard-retry'
const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4
const staleCleanupInFlight = new Map<string, Promise<void>>()
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
/** Reclaims preparations a crashed process left registered. Single-flighted per host key so a burst
* of arming calls shares one worktree listing. */
export async function cleanupStalePreparations(
cleanupKey: string,
repoPath: string,
options: AddWorktreeOptions
): Promise<void> {
const existing = staleCleanupInFlight.get(cleanupKey)
if (existing) {
await existing.catch(() => {})
return
}
const cleanup = (async () => {
// Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus
// a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create.
void retryPendingPreparationDiscards(cleanupKey)
const worktrees = await listWorktreeGraph(repoPath, {
...options,
includeCreatePreparations: true
})
const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation)
let nextIndex = 0
async function discardNextStalePreparation(): Promise<void> {
while (nextIndex < staleWorktrees.length) {
const worktree = staleWorktrees[nextIndex]
nextIndex += 1
const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason)
const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path)
if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) {
continue
}
// Preserve a branch-attached final path after a crash; only detached or
// still-hidden preparations are safe to discard automatically.
if (worktree.branch && pathOwnerPid === null) {
await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
} else if (pathOwnerPid === lockOwnerPid) {
await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
}
}
}
const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation()))
})()
staleCleanupInFlight.set(cleanupKey, cleanup)
try {
await cleanup.catch(() => {})
} finally {
if (staleCleanupInFlight.get(cleanupKey) === cleanup) {
staleCleanupInFlight.delete(cleanupKey)
}
}
}
export function resetStalePreparationCleanupForTests(): void {
staleCleanupInFlight.clear()
}
@@ -466,4 +466,51 @@ describe('worktree create preparation registry', () => {
expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true })
expect(mocks.discard).toHaveBeenCalledTimes(1)
})
function consumeOnce(name: string): ReturnType<typeof consumePreparedWorktreeCreate> {
return consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: `/workspace/${name}`,
branch: `feature/${name}`,
baseBranch: 'origin/main'
})
}
it('does not re-arm after an isolated create', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('only')).resolves.toEqual({})
// Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL.
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
})
it('re-arms a preparation once creates arrive in a burst', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('first')).resolves.toEqual({})
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2)
// No arming call follows this consume: the third checkout can only come from the re-arm.
await expect(consumeOnce('second')).resolves.toEqual({})
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3)
// The replacement is claimable, so a third create still skips the cold add.
await expect(consumeOnce('third')).resolves.toEqual({})
expect(mocks.finalize).toHaveBeenCalledTimes(3)
})
it('does not re-arm when finalization failed', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('first')).resolves.toEqual({})
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
mocks.prepareCheckout.mockClear()
mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move'))
await expect(consumeOnce('second')).resolves.toBeNull()
expect(mocks.prepareCheckout).not.toHaveBeenCalled()
})
})
+51 -74
View File
@@ -7,17 +7,12 @@ import { isFolderRepo } from '../shared/repo-kind'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
import {
WORKTREE_CREATE_PREPARATION_DIRECTORY,
createWorktreePreparationLockReason,
isWorktreeCreatePreparation,
parseWorktreePreparationOwnerPid,
parseWorktreePreparationPathOwnerPid
createWorktreePreparationLockReason
} from '../shared/worktree/create-preparation'
import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree'
import { listWorktreeGraph } from './git/worktree'
import {
discardPreparedWorktree,
finalizePreparedWorktree,
unlockPreparedWorktree,
prepareWorktreeCreateCheckout
} from './git/worktree-create-preparation'
import {
@@ -25,17 +20,23 @@ import {
getWorktreeMirrorDistro
} from './project-runtime-git-options'
import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic'
import {
recordPreparationConsume,
resetPreparationConsumeHistoryForTests
} from './worktree-create-preparation-burst'
import {
cleanupStalePreparations,
resetStalePreparationCleanupForTests
} from './worktree-create-preparation-stale-cleanup'
import { toHostFilesystemPath } from './host-tree-removal'
import {
discardPreparationWithRetry,
resetPendingPreparationDiscardsForTests,
retryPendingPreparationDiscards,
trackPreparationDiscard
} from './worktree-preparation-discard-retry'
export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000
export const WORKTREE_CREATE_PREPARATION_LIMIT = 3
const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4
type PreparationEntry = {
key: string
@@ -59,7 +60,6 @@ type ConsumePreparedWorktreeArgs = {
}
const preparations = new Map<string, PreparationEntry>()
const staleCleanupInFlight = new Map<string, Promise<void>>()
function pathOps(path: string): Pick<typeof posix, 'dirname' | 'join' | 'normalize'> {
return isWindowsAbsolutePathLike(path) ? win32 : posix
@@ -79,15 +79,6 @@ function preparationKey(
return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}`
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
function preparationHostKey(repoPath: string, options: AddWorktreeOptions): string {
return `${pathKey(repoPath)}\0${options.wslDistro ?? ''}`
}
@@ -131,57 +122,6 @@ function enforcePreparationLimit(): void {
}
}
async function cleanupStalePreparations(
repoPath: string,
options: AddWorktreeOptions
): Promise<void> {
const cleanupKey = preparationHostKey(repoPath, options)
const existing = staleCleanupInFlight.get(cleanupKey)
if (existing) {
await existing.catch(() => {})
return
}
const cleanup = (async () => {
// Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus
// a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create.
void retryPendingPreparationDiscards(cleanupKey)
const worktrees = await listWorktreeGraph(repoPath, {
...options,
includeCreatePreparations: true
})
const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation)
let nextIndex = 0
async function discardNextStalePreparation(): Promise<void> {
while (nextIndex < staleWorktrees.length) {
const worktree = staleWorktrees[nextIndex]
nextIndex += 1
const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason)
const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path)
if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) {
continue
}
// Preserve a branch-attached final path after a crash; only detached or
// still-hidden preparations are safe to discard automatically.
if (worktree.branch && pathOwnerPid === null) {
await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
} else if (pathOwnerPid === lockOwnerPid) {
await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {})
}
}
}
const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation()))
})()
staleCleanupInFlight.set(cleanupKey, cleanup)
try {
await cleanup.catch(() => {})
} finally {
if (staleCleanupInFlight.get(cleanupKey) === cleanup) {
staleCleanupInFlight.delete(cleanupKey)
}
}
}
export async function prepareWorktreeCreateForRepo(
store: Store,
repo: Repo,
@@ -205,6 +145,16 @@ export async function prepareWorktreeCreateForRepo(
return existing.ready
}
return startPreparation(key, repo.path, workspaceRoot, baseBranch, options)
}
function startPreparation(
key: string,
repoPath: string,
workspaceRoot: string,
baseBranch: string,
options: AddWorktreeOptions
): Promise<void> {
enforcePreparationLimit()
const preparationId = `${process.pid}-${randomUUID()}`
const lockReason = createWorktreePreparationLockReason(preparationId)
@@ -218,21 +168,21 @@ export async function prepareWorktreeCreateForRepo(
expiration.unref()
Object.assign(entry, {
key,
repoPath: repo.path,
repoPath,
workspaceRoot,
preparedPath,
options,
createdAt: Date.now(),
expiration,
ready: (async () => {
await cleanupStalePreparations(repo.path, options)
await cleanupStalePreparations(preparationHostKey(repoPath, options), repoPath, options)
await mkdir(
toHostFilesystemPath(
pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY)
),
{ recursive: true }
)
await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options)
await prepareWorktreeCreateCheckout(repoPath, preparedPath, baseBranch, lockReason, options)
})()
} satisfies PreparationEntry)
preparations.set(key, entry)
@@ -266,6 +216,28 @@ async function claimPreparedWorktree(
}
}
/** Replaces a just-consumed preparation, but only once the user has shown they are creating in a
* burst. A replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one
* after an isolated create spends that on nobody. Never awaited: create has already returned by
* the time the replacement checkout finishes. */
function rearmPreparation(entry: PreparationEntry, baseBranch: string): void {
// Record first: a prefetch that re-armed this key while we finalized would otherwise swallow the
// consume, and the next create would look isolated when it is really the middle of a burst.
const continuesBurst = recordPreparationConsume(entry.key)
if (preparations.has(entry.key) || !continuesBurst) {
return
}
void startPreparation(
entry.key,
entry.repoPath,
entry.workspaceRoot,
baseBranch,
entry.options
).catch(() => {
// Why: a warm-up failure is recovered by the normal add on the next create.
})
}
export async function consumePreparedWorktreeCreate(
args: ConsumePreparedWorktreeArgs
): Promise<AddWorktreeResult | null> {
@@ -283,7 +255,7 @@ export async function consumePreparedWorktreeCreate(
await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), {
recursive: true
})
return await finalizePreparedWorktree(
const result = await finalizePreparedWorktree(
args.repoPath,
entry.preparedPath,
args.worktreePath,
@@ -292,6 +264,10 @@ export async function consumePreparedWorktreeCreate(
args.refreshLocalBaseRef,
options
)
// Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is
// creating in a burst; the TTL and the preparation limit still bound an unused replacement.
rearmPreparation(entry, args.baseBranch)
return result
} catch (error) {
await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {})
console.warn(
@@ -305,7 +281,8 @@ export async function consumePreparedWorktreeCreate(
export async function _resetWorktreeCreatePreparationsForTests(): Promise<void> {
const entries = [...preparations.values()]
preparations.clear()
staleCleanupInFlight.clear()
resetPreparationConsumeHistoryForTests()
resetStalePreparationCleanupForTests()
await Promise.all(
entries.map(async (entry) => {
clearTimeout(entry.expiration)
+20 -6
View File
@@ -5,12 +5,26 @@ import { tmpdir } from 'node:os'
import type { PersistedState } from '../shared/persisted-state-types'
import { canonicalWorktreeIdentity } from '../shared/worktree/identity'
import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity'
import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness'
import type { Store } from './persistence/loading-store/store'
import {
createStore,
makeRepo,
readDataFile,
testState,
writeDataFile
} from './persistence-test-harness'
describe('host-qualified worktree metadata', () => {
const worktreeId = 'repo-1::/workspace/feature'
const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444'
// Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load.
const createStoreWithRepo = (): Store => {
const store = createStore()
store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' }))
return store
}
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-'))
})
@@ -52,7 +66,7 @@ describe('host-qualified worktree metadata', () => {
})
})
it('reloads host-specific metadata without collapsing it to the legacy locator', () => {
const store = createStore()
const store = createStoreWithRepo()
store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' })
store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' })
store.flush()
@@ -72,7 +86,7 @@ describe('host-qualified worktree metadata', () => {
expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after')
})
it('backfills one stable instance for legacy metadata that omitted it', () => {
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' })
seed.flush()
const legacy = readDataFile() as PersistedState
@@ -97,7 +111,7 @@ describe('host-qualified worktree metadata', () => {
// Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree
// listing loop, taking every workspace in the repo down with it and never self-healing.
it('collapses an ambiguous locator onto its most recently active instance', () => {
const seed = createStore()
const seed = createStoreWithRepo()
const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -262,7 +276,7 @@ describe('host-qualified worktree metadata', () => {
it('repairs a missing canonical instance id while re-adopting an SSH target', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -318,7 +332,7 @@ describe('host-qualified worktree metadata', () => {
it('deduplicates an equivalent destination during SSH target re-adoption', () => {
const oldHostId = 'ssh:old-target' as const
const newHostId = 'ssh:new-target' as const
const seed = createStore()
const seed = createStoreWithRepo()
seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' })
seed.flush()
const persisted = readDataFile() as PersistedState
@@ -16,7 +16,7 @@
*/
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { getShellLaunchConfig } from './providers/local-pty-shell-ready'
import { selectShellStartupFeatures } from './shell-startup-features'
@@ -382,9 +382,12 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui
// value this wrapper cannot use degrades to $HOME, where zsh itself looks.
const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' })
try {
// Unique per run: a fixed name here shares one path with every other run in
// the system temp dir, so a killed run leaves a stale directory behind and
// every later rename onto it fails with ENOTEMPTY.
const { values } = await runFromRelocatedRoot(
home,
join(dirname(userDataPath), '홍길동-wsl-view')
join(dirname(userDataPath), `홍길동-${basename(userDataPath)}`)
)
expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1')
+2 -1
View File
@@ -6,6 +6,7 @@ import type {
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
import type { PreloadApi } from '../api-types'
export const agentStatusApi = {
/** Listen for agent status updates forwarded from native hook receivers. */
@@ -85,4 +86,4 @@ export const agentStatusApi = {
}): void => {
ipcRenderer.send('agentStatus:transferPaneAuthority', args)
}
}
} satisfies PreloadApi['agentStatus']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const agentTrustApi = {
markTrusted: (args: {
@@ -6,4 +7,4 @@ export const agentTrustApi = {
workspacePath: string
connectionId?: string
}): Promise<void> => ipcRenderer.invoke('agentTrust:markTrusted', args)
}
} satisfies PreloadApi['agentTrust']
+7 -7
View File
@@ -10,19 +10,19 @@ import type {
} from '../../shared/ai-vault-types'
import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title'
import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation'
import type { PreloadApi } from '../api-types'
export const aiVaultApi = {
listSessions: (args?: AiVaultListArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:listSessions', args),
resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise<unknown> =>
listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args),
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) =>
ipcRenderer.invoke('aiVault:resolveSessionTitles', args),
cancelListSessions: (args: { requestToken: string }): Promise<void> =>
ipcRenderer.invoke('aiVault:cancelListSessions', args),
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise<unknown> =>
prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) =>
ipcRenderer.invoke('aiVault:prepareSessionResume', args),
listSubagentSessions: (args: AiVaultSubagentListArgs): Promise<unknown> =>
listSubagentSessions: (args: AiVaultSubagentListArgs) =>
ipcRenderer.invoke('aiVault:listSubagentSessions', args),
getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise<unknown> =>
getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) =>
ipcRenderer.invoke('aiVault:getFirstUserPrompt', args),
deleteSession: (args: AiVaultDeleteSessionArgs): Promise<AiVaultDeleteSessionResult> =>
ipcRenderer.invoke('aiVault:deleteSession', args),
@@ -31,4 +31,4 @@ export const aiVaultApi = {
ipcRenderer.on('aiVault:windowFocused', listener)
return () => ipcRenderer.removeListener('aiVault:windowFocused', listener)
}
}
} satisfies PreloadApi['aiVault']
+2 -1
View File
@@ -40,6 +40,7 @@ export const appApi = {
throw new Error('Failed to stage renderer state before unload.')
}
},
awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(),
awaitFirstWindowStartupServices: (): Promise<void> =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
prepareTerminalStartupRestoration: (): Promise<void> =>
@@ -73,4 +74,4 @@ export const appApi = {
ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'),
writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) =>
ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args)
}
} satisfies PreloadApi['app']
+2 -2
View File
@@ -1,5 +1,5 @@
import { ipcRenderer } from 'electron'
import type { ExternalAutomationManagerResult } from '../api-types'
import type { ExternalAutomationManagerResult, PreloadApi } from '../api-types'
import type {
AutomationDispatchRequest,
AutomationDispatchResult,
@@ -56,4 +56,4 @@ export const automationsApi = {
ipcRenderer.on('automations:changed', listener)
return () => ipcRenderer.removeListener('automations:changed', listener)
}
}
} satisfies PreloadApi['automations']
+3 -2
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const bitbucketApi = {
connect: (args: {
@@ -12,5 +13,5 @@ export const bitbucketApi = {
disconnect: (): Promise<void> => ipcRenderer.invoke('bitbucket:disconnect'),
status: (): Promise<unknown> => ipcRenderer.invoke('bitbucket:status')
}
status: () => ipcRenderer.invoke('bitbucket:status')
} satisfies PreloadApi['bitbucket']
@@ -6,6 +6,7 @@ import type {
} from '../../shared/browser-webauthn-account'
import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument'
import { browserClientPageRendererRequests } from '../preload-runtime-support'
import type { PreloadApi } from '../api-types'
export const browserGuestRegistrationAndDownloadsApi = {
onClientPageRendererRequest: browserClientPageRendererRequests.subscribe,
@@ -194,4 +195,4 @@ export const browserGuestRegistrationAndDownloadsApi = {
ipcRenderer.on('browser:download-finished', listener)
return () => ipcRenderer.removeListener('browser:download-finished', listener)
}
}
} satisfies Partial<PreloadApi['browser']>
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const browserPageInteractionAndSessionsApi = {
onContextMenuRequested: (
@@ -81,23 +82,17 @@ export const browserPageInteractionAndSessionsApi = {
},
cancelDownload: (args: { downloadId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:cancelDownload', args),
setGrabMode: (args: {
browserPageId: string
enabled: boolean
}): Promise<{ ok: true } | { ok: false; reason: string }> =>
setGrabMode: (args: { browserPageId: string; enabled: boolean }) =>
ipcRenderer.invoke('browser:setGrabMode', args),
awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise<unknown> =>
awaitGrabSelection: (args: { browserPageId: string; opId: string }) =>
ipcRenderer.invoke('browser:awaitGrabSelection', args),
cancelGrab: (args: { browserPageId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:cancelGrab', args),
captureSelectionScreenshot: (args: {
browserPageId: string
rect: { x: number; y: number; width: number; height: number }
}): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> =>
ipcRenderer.invoke('browser:captureSelectionScreenshot', args),
extractHoverPayload: (args: {
browserPageId: string
}): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> =>
}) => ipcRenderer.invoke('browser:captureSelectionScreenshot', args),
extractHoverPayload: (args: { browserPageId: string }) =>
ipcRenderer.invoke('browser:extractHoverPayload', args),
onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) =>
@@ -115,7 +110,7 @@ export const browserPageInteractionAndSessionsApi = {
ipcRenderer.on('browser:grabActionShortcut', listener)
return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener)
},
sessionListProfiles: (): Promise<unknown[]> => ipcRenderer.invoke('browser:session:listProfiles'),
sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'),
prepareSshWorkspacePartition: (args: {
targetId: string
browserProfileId?: string
@@ -126,40 +121,28 @@ export const browserPageInteractionAndSessionsApi = {
scope: 'default' | 'isolated' | 'imported'
label: string
userAgentMode?: 'clean' | 'native'
}): Promise<unknown> => ipcRenderer.invoke('browser:session:createProfile', args),
}) => ipcRenderer.invoke('browser:session:createProfile', args),
sessionDeleteProfile: (args: { profileId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:session:deleteProfile', args),
sessionImportCookies: (args: {
profileId: string
}): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> =>
sessionImportCookies: (args: { profileId: string }) =>
ipcRenderer.invoke('browser:session:importCookies', args),
sessionResolvePartition: (args: { profileId: string | null }): Promise<string | null> =>
ipcRenderer.invoke('browser:session:resolvePartition', args),
sessionDetectBrowsers: (): Promise<unknown[]> =>
ipcRenderer.invoke('browser:session:detectBrowsers'),
sessionDetectBrowsersForClientHost: (args: {
environmentId: string
}): Promise<unknown[] | null> =>
sessionDetectBrowsers: () => ipcRenderer.invoke('browser:session:detectBrowsers'),
sessionDetectBrowsersForClientHost: (args: { environmentId: string }) =>
ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args),
sessionImportFromBrowser: (args: {
profileId: string
browserFamily: string
}): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> =>
sessionImportFromBrowser: (args: { profileId: string; browserFamily: string }) =>
ipcRenderer.invoke('browser:session:importFromBrowser', args),
sessionImportFromBrowserForClientHost: (args: {
environmentId: string
profileId: string
browserFamily: string
browserProfile?: string
}): Promise<
{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null
> => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args),
sessionClientRouteImportSources: (args: {
environmentId: string
}): Promise<Record<string, unknown>> =>
}) => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args),
sessionClientRouteImportSources: (args: { environmentId: string }) =>
ipcRenderer.invoke('browser:session:clientRouteImportSources', args),
sessionClearDefaultCookies: (): Promise<boolean> =>
ipcRenderer.invoke('browser:session:clearDefaultCookies'),
notifyActiveTabChanged: (args: { browserPageId: string }): Promise<boolean> =>
ipcRenderer.invoke('browser:activeTabChanged', args)
}
} satisfies Partial<PreloadApi['browser']>
+2 -1
View File
@@ -1,7 +1,8 @@
import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads'
import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions'
import type { PreloadApi } from '../api-types'
export const browserApi = {
...browserGuestRegistrationAndDownloadsApi,
...browserPageInteractionAndSessionsApi
}
} satisfies PreloadApi['browser']
+7 -7
View File
@@ -1,18 +1,18 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const claudeAccountsApi = {
list: (): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise<unknown> =>
list: () => ipcRenderer.invoke('claudeAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) =>
ipcRenderer.invoke('claudeAccounts:add', args),
cancelPendingLogin: (): Promise<boolean> =>
ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'),
reauthenticate: (args: { accountId: string }): Promise<unknown> =>
reauthenticate: (args: { accountId: string }) =>
ipcRenderer.invoke('claudeAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('claudeAccounts:remove', args),
remove: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:remove', args),
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}): Promise<unknown> => ipcRenderer.invoke('claudeAccounts:select', args)
}
}) => ipcRenderer.invoke('claudeAccounts:select', args)
} satisfies PreloadApi['claudeAccounts']
+5 -1
View File
@@ -1,4 +1,8 @@
import { ipcRenderer } from 'electron'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const claudeUsageApi = createUsageProviderApi(ipcRenderer, 'claudeUsage')
export const claudeUsageApi = createUsageProviderApi(
ipcRenderer,
'claudeUsage'
) satisfies PreloadApi['claudeUsage']
+2 -1
View File
@@ -1,5 +1,6 @@
import { ipcRenderer } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import type { PreloadApi } from '../api-types'
export const cliApi = {
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
@@ -11,4 +12,4 @@ export const cliApi = {
ipcRenderer.invoke('cli:installWsl', args),
removeWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
ipcRenderer.invoke('cli:removeWsl', args)
}
} satisfies PreloadApi['cli']
+8 -10
View File
@@ -1,20 +1,18 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const codexAccountsApi = {
list: (): Promise<unknown> => ipcRenderer.invoke('codexAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise<unknown> =>
list: () => ipcRenderer.invoke('codexAccounts:list'),
add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) =>
ipcRenderer.invoke('codexAccounts:add', args),
reauthenticate: (args: {
accountId: string
activateIfSelectionWasEmpty?: boolean
}): Promise<unknown> => ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }): Promise<unknown> =>
ipcRenderer.invoke('codexAccounts:remove', args),
reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) =>
ipcRenderer.invoke('codexAccounts:reauthenticate', args),
remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args),
select: (args: {
accountId: string | null
runtime?: 'host' | 'wsl'
wslDistro?: string | null
}): Promise<unknown> => ipcRenderer.invoke('codexAccounts:select', args),
}) => ipcRenderer.invoke('codexAccounts:select', args),
listStalePanes: (args: {
ptyIds: string[]
}): Promise<
@@ -29,4 +27,4 @@ export const codexAccountsApi = {
ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args),
forgetStalePanes: (args: { ptyIds: string[] }): Promise<void> =>
ipcRenderer.invoke('codexAccounts:forgetStalePanes', args)
}
} satisfies PreloadApi['codexAccounts']
+2 -1
View File
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron'
import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types'
import type { PreloadApi } from '../api-types'
export const codexConfigSyncApi = {
status: (): Promise<CodexConfigSyncStatus> => ipcRenderer.invoke('codexConfigSync:status')
}
} satisfies PreloadApi['codexConfigSync']
+5 -1
View File
@@ -1,4 +1,8 @@
import { ipcRenderer } from 'electron'
import { createUsageProviderApi } from '../usage-provider-api'
import type { PreloadApi } from '../api-types'
export const codexUsageApi = createUsageProviderApi(ipcRenderer, 'codexUsage')
export const codexUsageApi = createUsageProviderApi(
ipcRenderer,
'codexUsage'
) satisfies PreloadApi['codexUsage']
@@ -1,8 +1,9 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const computerUsePermissionsApi = {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('computerUsePermissions:getStatus'),
openSetup: (args?: { id?: string }): Promise<unknown> =>
getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'),
openSetup: (args?: { id?: string }) =>
ipcRenderer.invoke('computerUsePermissions:openSetup', args),
reset: (): Promise<unknown> => ipcRenderer.invoke('computerUsePermissions:reset')
}
reset: () => ipcRenderer.invoke('computerUsePermissions:reset')
} satisfies PreloadApi['computerUsePermissions']
+2 -1
View File
@@ -11,6 +11,7 @@ import type { RendererHeapStatistics } from '../../shared/renderer-heap-statisti
import type { RendererProcessMemory } from '../../shared/renderer-process-memory'
import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader'
import { readRendererProcessMemory } from '../renderer-process-memory-reader'
import type { PreloadApi } from '../api-types'
export const crashReportsApi = {
getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'),
@@ -28,4 +29,4 @@ export const crashReportsApi = {
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args),
readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(),
readProcessMemory: (): Promise<RendererProcessMemory | null> => readRendererProcessMemory()
}
} satisfies PreloadApi['crashReports']
+2 -1
View File
@@ -5,6 +5,7 @@ import type {
DashboardSnapshot,
DashboardSpawnAgentArgs
} from '../../shared/dashboard-snapshot'
import type { PreloadApi } from '../api-types'
export const dashboardApi = {
// Open the pop-out dashboard window, or focus it if already open.
@@ -71,4 +72,4 @@ export const dashboardApi = {
ipcRenderer.invoke('dashboardPopout:spawnAgent', args),
sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise<void> =>
ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args)
}
} satisfies PreloadApi['dashboard']
@@ -1,11 +1,11 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const developerPermissionsApi = {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('developerPermissions:getStatus'),
request: (args: { id: string }): Promise<unknown> =>
ipcRenderer.invoke('developerPermissions:request', args),
getStatus: () => ipcRenderer.invoke('developerPermissions:getStatus'),
request: (args: { id: string }) => ipcRenderer.invoke('developerPermissions:request', args),
openSettings: (args: { id: string }): Promise<void> =>
ipcRenderer.invoke('developerPermissions:openSettings', args),
testLocalNetworkConnection: (args: { host: string; port: number }): Promise<unknown> =>
testLocalNetworkConnection: (args: { host: string; port: number }) =>
ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args)
}
} satisfies PreloadApi['developerPermissions']
+5 -4
View File
@@ -1,15 +1,16 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const diagnosticsApi = {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('diagnostics:getStatus'),
collectBundle: (lookbackMinutes?: number): Promise<unknown> =>
getStatus: () => ipcRenderer.invoke('diagnostics:getStatus'),
collectBundle: (lookbackMinutes?: number) =>
ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes),
openBundlePreview: (bundleSubmissionId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId),
discardBundlePreview: (bundleSubmissionId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId),
uploadBundle: (bundleSubmissionId: string): Promise<unknown> =>
uploadBundle: (bundleSubmissionId: string) =>
ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId),
deleteBundle: (ticketId: string): Promise<void> =>
ipcRenderer.invoke('diagnostics:deleteBundle', ticketId)
}
} satisfies PreloadApi['diagnostics']
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type DocPreviewFailure
} from '../../shared/doc-preview-scheme'
import type { DocPreviewGrantRequest } from '../api/doc-preview-api'
import type { PreloadApi } from '../api-types'
export const docPreviewApi = {
mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> =>
@@ -28,4 +29,4 @@ export const docPreviewApi = {
ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
}
}
} satisfies PreloadApi['docPreview']
+2 -1
View File
@@ -1,5 +1,6 @@
import { preloadE2EConfig } from '../e2e-config'
import type { PreloadApi } from '../api-types'
export const e2eApi = {
getConfig: () => preloadE2EConfig
}
} satisfies PreloadApi['e2e']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const emulatorApi = {
startFrameStream: (args: {
@@ -95,4 +96,4 @@ export const emulatorApi = {
ipcRenderer.on('ui:emulatorAutoAttach', listener)
return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener)
}
}
} satisfies PreloadApi['emulator']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const exportApi = {
htmlToPdf: (args: {
@@ -7,4 +8,4 @@ export const exportApi = {
}): Promise<
{ success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string }
> => ipcRenderer.invoke('export:html-to-pdf', args)
}
} satisfies PreloadApi['export']
+2 -1
View File
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const feedbackApi = {
submit: (args: {
@@ -10,4 +11,4 @@ export const feedbackApi = {
}): Promise<
{ ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string }
> => ipcRenderer.invoke('feedback:submit', args)
}
} satisfies PreloadApi['feedback']
+2 -1
View File
@@ -8,6 +8,7 @@ import type {
LocalLogTailReadResult,
LocalLogTailWatchArgs
} from '../../shared/local-log-tail-types'
import type { PreloadApi } from '../api-types'
export const fsApi = {
readDir: (args: {
@@ -216,4 +217,4 @@ export const fsApi = {
ipcRenderer.on('fs:changed', listener)
return () => ipcRenderer.removeListener('fs:changed', listener)
}
}
} satisfies PreloadApi['fs']
@@ -35,11 +35,12 @@ import type {
UpdateProjectItemFieldArgs
} from '../../shared/github/project-request-types'
import type { AppStarSource } from '../../shared/gh-star-source'
import type { PreloadApi } from '../api-types'
export const ghMutationsAndProjectsApi = {
setPRAutoMerge: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
enabled: boolean
@@ -49,7 +50,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:setPRAutoMerge', args),
updatePRState: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
updates: { state: 'open' | 'closed' }
@@ -58,7 +59,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:updatePRState', args),
markPRReadyForReview: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -66,7 +67,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:markPRReadyForReview', args),
requestPRReviewers: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
reviewers: string[]
@@ -75,7 +76,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:requestPRReviewers', args),
removePRReviewers: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
reviewers: string[]
@@ -84,7 +85,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:removePRReviewers', args),
updateIssue: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
updates: unknown
@@ -92,7 +93,7 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:updateIssue', args),
addIssueComment: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
body: string
@@ -101,7 +102,7 @@ export const ghMutationsAndProjectsApi = {
}): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addIssueComment', args),
addPRReviewCommentReply: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
commentId: number
@@ -113,7 +114,7 @@ export const ghMutationsAndProjectsApi = {
}): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addPRReviewCommentReply', args),
addPRReviewComment: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -125,12 +126,12 @@ export const ghMutationsAndProjectsApi = {
}): Promise<GitHubCommentResult> => ipcRenderer.invoke('gh:addPRReviewComment', args),
listLabels: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}): Promise<string[]> => ipcRenderer.invoke('gh:listLabels', args),
listAssignableUsers: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
}): Promise<GitHubAssignableUser[]> => ipcRenderer.invoke('gh:listAssignableUsers', args),
onWorkItemMutated: (
@@ -199,4 +200,4 @@ export const ghMutationsAndProjectsApi = {
ipcRenderer.invoke('gh:listIssueTypesBySlug', args),
updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:updateIssueTypeBySlug', args)
}
} satisfies Partial<PreloadApi['gh']>
@@ -9,33 +9,34 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types'
import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types'
import type { TaskSourceContext } from '../../shared/task-source-context'
import type { PreloadApi } from '../api-types'
export const ghPullRequestsAndWorkItemsApi = {
viewer: (): Promise<unknown> => ipcRenderer.invoke('gh:viewer'),
repoSlug: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
viewer: () => ipcRenderer.invoke('gh:viewer'),
repoSlug: (args: { repoPath: string; repoId?: string }) =>
ipcRenderer.invoke('gh:repoSlug', args),
repoUpstream: (args: { repoPath: string; repoId?: string }): Promise<unknown> =>
repoUpstream: (args: { repoPath: string; repoId?: string }) =>
ipcRenderer.invoke('gh:repoUpstream', args),
prForBranch: (args: {
repoPath: string
repoId?: string
repoId?: string | null
branch: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
acceptMergedFallbackPR?: boolean
currentHeadOid?: string | null
}): Promise<unknown> => ipcRenderer.invoke('gh:prForBranch', args),
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise<unknown> =>
}) => ipcRenderer.invoke('gh:prForBranch', args),
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) =>
ipcRenderer.invoke('gh:refreshPRNow', args),
enqueuePRRefresh: (args: {
candidate: GitHubPRRefreshCandidate
reason: GitHubPRRefreshReason
priority?: number
}): Promise<unknown> => ipcRenderer.invoke('gh:enqueuePRRefresh', args),
}) => ipcRenderer.invoke('gh:enqueuePRRefresh', args),
reportVisiblePRRefreshCandidates: (args: {
candidates: GitHubPRRefreshCandidate[]
generation: number
}): Promise<unknown> => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args),
}) => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args),
onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void =>
callback(event)
@@ -44,42 +45,42 @@ export const ghPullRequestsAndWorkItemsApi = {
},
issue: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
}): Promise<unknown> => ipcRenderer.invoke('gh:issue', args),
}) => ipcRenderer.invoke('gh:issue', args),
workItem: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
type?: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItem', args),
}) => ipcRenderer.invoke('gh:workItem', args),
workItemByOwnerRepo: (args: {
repoPath: string
repoId?: string
repoId?: string | null
owner: string
repo: string
host?: string
number: number
type: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemByOwnerRepo', args),
}) => ipcRenderer.invoke('gh:workItemByOwnerRepo', args),
workItemDetails: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
number: number
type?: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemDetails', args),
}) => ipcRenderer.invoke('gh:workItemDetails', args),
notifyWorkItemMutated: (args: {
repoPath: string
repoId?: string
repoId?: string | null
type: 'issue' | 'pr'
number: number
}): Promise<boolean> => ipcRenderer.invoke('gh:notifyWorkItemMutated', args),
prFileContents: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -88,12 +89,12 @@ export const ghPullRequestsAndWorkItemsApi = {
status: string
headSha: string
baseSha: string
}): Promise<unknown> => ipcRenderer.invoke('gh:prFileContents', args),
listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise<unknown[]> =>
}) => ipcRenderer.invoke('gh:prFileContents', args),
listIssues: (args: { repoPath: string; repoId?: string; limit?: number }) =>
ipcRenderer.invoke('gh:listIssues', args),
createIssue: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
title: string
body: string
@@ -104,7 +105,7 @@ export const ghPullRequestsAndWorkItemsApi = {
ipcRenderer.invoke('gh:countWorkItems', args),
listWorkItems: (args: {
repoPath: string
repoId?: string
repoId?: string | null
limit?: number
query?: string
page?: number
@@ -113,26 +114,26 @@ export const ghPullRequestsAndWorkItemsApi = {
ipcRenderer.invoke('gh:listWorkItems', args),
prChecks: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
headSha?: string
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prChecks', args),
}) => ipcRenderer.invoke('gh:prChecks', args),
prCheckDetails: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
checkRunId?: number
workflowRunId?: number
checkName?: string
url?: string | null
prRepo?: GitHubOwnerRepo | null
}): Promise<unknown> => ipcRenderer.invoke('gh:prCheckDetails', args),
}) => ipcRenderer.invoke('gh:prCheckDetails', args),
rerunPRChecks: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
headSha?: string
@@ -142,15 +143,15 @@ export const ghPullRequestsAndWorkItemsApi = {
ipcRenderer.invoke('gh:rerunPRChecks', args),
prComments: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}): Promise<unknown[]> => ipcRenderer.invoke('gh:prComments', args),
}) => ipcRenderer.invoke('gh:prComments', args),
setPRCommentReaction: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
reactionSubjectId: string
content: GitHubReactionContent
@@ -159,7 +160,7 @@ export const ghPullRequestsAndWorkItemsApi = {
}): Promise<boolean> => ipcRenderer.invoke('gh:setPRCommentReaction', args),
resolveReviewThread: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
threadId: string
resolve: boolean
@@ -167,7 +168,7 @@ export const ghPullRequestsAndWorkItemsApi = {
}): Promise<boolean> => ipcRenderer.invoke('gh:resolveReviewThread', args),
setPRFileViewed: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
@@ -177,17 +178,17 @@ export const ghPullRequestsAndWorkItemsApi = {
}): Promise<boolean> => ipcRenderer.invoke('gh:setPRFileViewed', args),
updatePRTitle: (args: {
repoPath: string
repoId?: string
repoId?: string | null
prNumber: number
title: string
prRepo?: GitHubOwnerRepo | null
}): Promise<boolean> => ipcRenderer.invoke('gh:updatePRTitle', args),
mergePR: (args: {
repoPath: string
repoId?: string
repoId?: string | null
sourceContext?: TaskSourceContext | null
prNumber: number
method?: 'merge' | 'squash' | 'rebase'
prRepo?: GitHubOwnerRepo | null
}): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args)
}
} satisfies Partial<PreloadApi['gh']>
+5 -1
View File
@@ -1,4 +1,8 @@
import type { PreloadApi } from '../api-types'
import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items'
import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects'
export const ghApi = { ...ghPullRequestsAndWorkItemsApi, ...ghMutationsAndProjectsApi }
export const ghApi = {
...ghPullRequestsAndWorkItemsApi,
...ghMutationsAndProjectsApi
} satisfies PreloadApi['gh']
+2 -1
View File
@@ -1,5 +1,6 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const gitBashApi = {
isAvailable: (): Promise<boolean> => ipcRenderer.invoke('gitBash:isAvailable')
}
} satisfies PreloadApi['gitBash']
+15 -20
View File
@@ -3,6 +3,7 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../share
import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { PreloadApi } from '../api-types'
export const gitApi = {
status: (args: {
@@ -13,7 +14,7 @@ export const gitApi = {
reuseLineStats?: boolean
branchLineTotalMergeBase?: string
requestToken?: string
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
}) => ipcRenderer.invoke('git:status', args),
cancelStatus: (args: { requestToken: string }): Promise<void> =>
ipcRenderer.invoke('git:cancelStatus', args),
setStatusUpstreamRefWatch: (args: {
@@ -29,7 +30,7 @@ export const gitApi = {
submodulePath: string
connectionId?: string
area?: GitStagingArea
}): Promise<unknown> => ipcRenderer.invoke('git:submoduleStatus', args),
}) => ipcRenderer.invoke('git:submoduleStatus', args),
checkIgnored: (args: {
worktreePath: string
paths: string[]
@@ -42,7 +43,7 @@ export const gitApi = {
history: (
args: { worktreePath: string; connectionId?: string } & GitHistoryOptions
): Promise<GitHistoryResult> => ipcRenderer.invoke('git:history', args),
conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise<unknown> =>
conflictOperation: (args: { worktreePath: string; connectionId?: string }) =>
ipcRenderer.invoke('git:conflictOperation', args),
abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
ipcRenderer.invoke('git:abortMerge', args),
@@ -54,17 +55,11 @@ export const gitApi = {
staged: boolean
compareAgainstHead?: boolean
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:diff', args),
branchCompare: (args: {
worktreePath: string
baseRef: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:branchCompare', args),
commitCompare: (args: {
worktreePath: string
commitId: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:commitCompare', args),
}) => ipcRenderer.invoke('git:diff', args),
branchCompare: (args: { worktreePath: string; baseRef: string; connectionId?: string }) =>
ipcRenderer.invoke('git:branchCompare', args),
commitCompare: (args: { worktreePath: string; commitId: string; connectionId?: string }) =>
ipcRenderer.invoke('git:commitCompare', args),
upstreamStatus: (args: {
worktreePath: string
connectionId?: string
@@ -108,7 +103,7 @@ export const gitApi = {
filePath: string
oldPath?: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:branchDiff', args),
}) => ipcRenderer.invoke('git:branchDiff', args),
commitDiff: (args: {
worktreePath: string
commitOid: string
@@ -116,7 +111,7 @@ export const gitApi = {
filePath: string
oldPath?: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:commitDiff', args),
}) => ipcRenderer.invoke('git:commitDiff', args),
commit: (args: {
worktreePath: string
message: string
@@ -130,12 +125,12 @@ export const gitApi = {
sourceControlAiResolvedParams?: unknown
sourceControlAi?: unknown
agentCmdOverrides?: Record<string, string>
}): Promise<unknown> => ipcRenderer.invoke('git:generateCommitMessage', args),
}) => ipcRenderer.invoke('git:generateCommitMessage', args),
discoverCommitMessageModels: (args: {
agentId: string
worktreePath?: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:discoverCommitMessageModels', args),
}) => ipcRenderer.invoke('git:discoverCommitMessageModels', args),
cancelGenerateCommitMessage: (args: {
worktreePath: string
connectionId?: string
@@ -154,7 +149,7 @@ export const gitApi = {
sourceControlAiResolvedParams?: unknown
sourceControlAi?: unknown
agentCmdOverrides?: Record<string, string>
}): Promise<unknown> => ipcRenderer.invoke('git:generatePullRequestFields', args),
}) => ipcRenderer.invoke('git:generatePullRequestFields', args),
cancelGeneratePullRequestFields: (args: {
worktreePath: string
connectionId?: string
@@ -197,4 +192,4 @@ export const gitApi = {
sha: string
connectionId?: string
}): Promise<string | null> => ipcRenderer.invoke('git:remoteCommitUrl', args)
}
} satisfies PreloadApi['git']
+2 -1
View File
@@ -1,3 +1,4 @@
import { glApi } from '../gitlab'
import type { PreloadApi } from '../api-types'
export const glApiBridge = glApi
export const glApiBridge = glApi satisfies PreloadApi['gl']
+2 -1
View File
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron'
import type { GrokAccountStatus } from '../../shared/rate-limit-types'
import type { PreloadApi } from '../api-types'
export const grokAccountsApi = {
getStatus: (): Promise<GrokAccountStatus> => ipcRenderer.invoke('grokAccounts:getStatus')
}
} satisfies PreloadApi['grokAccounts']
+6 -14
View File
@@ -1,22 +1,14 @@
import { ipcRenderer } from 'electron'
import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { PreloadApi } from '../api-types'
export const hooksApi = {
check: (args: {
repoId: string
hostId?: ExecutionHostId
}): Promise<{
status?: 'ok' | 'error'
hasHooks: boolean
hooks: unknown
mayNeedUpdate: boolean
}> => ipcRenderer.invoke('hooks:check', args),
check: (args: { repoId: string; hostId?: ExecutionHostId }) =>
ipcRenderer.invoke('hooks:check', args),
inspectSetupScriptImports: (args: {
repoId: string
hostId?: ExecutionHostId
}): Promise<unknown[]> => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args),
inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) =>
ipcRenderer.invoke('hooks:inspectSetupScriptImports', args),
createIssueCommandRunner: (args: {
repoId: string
@@ -41,4 +33,4 @@ export const hooksApi = {
content: string
hostId?: ExecutionHostId
}): Promise<void> => ipcRenderer.invoke('hooks:writeIssueCommand', args)
}
} satisfies PreloadApi['hooks']
+6 -6
View File
@@ -1,12 +1,12 @@
import { ipcRenderer } from 'electron'
import type { HostedReviewForBranchArgs } from '../../shared/hosted-review'
import type { PreloadApi } from '../api-types'
export const hostedReviewApi = {
forBranch: (args: HostedReviewForBranchArgs): Promise<unknown> =>
forBranch: (args: HostedReviewForBranchArgs) =>
ipcRenderer.invoke('hostedReview:forBranch', args),
getCreationEligibility: (args: unknown): Promise<unknown> =>
getCreationEligibility: (args: unknown) =>
ipcRenderer.invoke('hostedReview:getCreationEligibility', args),
create: (args: unknown): Promise<unknown> => ipcRenderer.invoke('hostedReview:create', args),
createStacked: (args: unknown): Promise<unknown> =>
ipcRenderer.invoke('hostedReview:createStacked', args)
}
create: (args: unknown) => ipcRenderer.invoke('hostedReview:create', args),
createStacked: (args: unknown) => ipcRenderer.invoke('hostedReview:createStacked', args)
} satisfies PreloadApi['hostedReview']
+23 -43
View File
@@ -1,5 +1,6 @@
import { ipcRenderer } from 'electron'
import type { JiraProjectStatusOrder } from '../../shared/jira-types'
import type { PreloadApi } from '../api-types'
export const jiraApi = {
connect: (args: {
@@ -7,30 +8,21 @@ export const jiraApi = {
email: string
apiToken: string
authType?: 'cloud' | 'server'
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
ipcRenderer.invoke('jira:connect', args),
}) => ipcRenderer.invoke('jira:connect', args),
disconnect: (args?: { siteId?: string }): Promise<void> =>
ipcRenderer.invoke('jira:disconnect', args),
selectSite: (args: { siteId: string }): Promise<unknown> =>
ipcRenderer.invoke('jira:selectSite', args),
selectSite: (args: { siteId: string }) => ipcRenderer.invoke('jira:selectSite', args),
status: (): Promise<unknown> => ipcRenderer.invoke('jira:status'),
status: () => ipcRenderer.invoke('jira:status'),
readStatus: (): Promise<unknown> => ipcRenderer.invoke('jira:readStatus'),
readStatus: () => ipcRenderer.invoke('jira:readStatus'),
testConnection: (args?: {
siteId?: string
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
ipcRenderer.invoke('jira:testConnection', args),
testConnection: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:testConnection', args),
searchIssues: (args: {
jql: string
limit?: number
siteId?: string
requestId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:searchIssues', args),
searchIssues: (args: { jql: string; limit?: number; siteId?: string; requestId?: string }) =>
ipcRenderer.invoke('jira:searchIssues', args),
cancelSearchIssues: (args: { requestId: string }): Promise<void> =>
ipcRenderer.invoke('jira:cancelSearchIssues', args),
@@ -38,16 +30,12 @@ export const jiraApi = {
filter?: 'assigned' | 'reported' | 'all' | 'done'
limit?: number
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listIssues', args),
}) => ipcRenderer.invoke('jira:listIssues', args),
getIssue: (args: { key: string; siteId?: string }): Promise<unknown> =>
ipcRenderer.invoke('jira:getIssue', args),
getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args),
lookupIssueSummary: (args: {
key: string
siteId: string
requestId?: string
}): Promise<unknown> => ipcRenderer.invoke('jira:lookupIssueSummary', args),
lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) =>
ipcRenderer.invoke('jira:lookupIssueSummary', args),
cancelIssueSummary: (args: { requestId: string }): Promise<void> =>
ipcRenderer.invoke('jira:cancelIssueSummary', args),
@@ -75,36 +63,28 @@ export const jiraApi = {
}): Promise<{ ok: true; id: string } | { ok: false; error: string }> =>
ipcRenderer.invoke('jira:addIssueComment', args),
issueComments: (args: { key: string; siteId?: string }): Promise<unknown[]> =>
issueComments: (args: { key: string; siteId?: string }) =>
ipcRenderer.invoke('jira:issueComments', args),
listProjects: (args?: { siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listProjects', args),
listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args),
listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise<unknown[]> =>
listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) =>
ipcRenderer.invoke('jira:listIssueTypes', args),
listCreateFields: (args: {
projectIdOrKey: string
issueTypeId: string
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listCreateFields', args),
listCreateFields: (args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) =>
ipcRenderer.invoke('jira:listCreateFields', args),
listPriorities: (args?: { siteId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('jira:listPriorities', args),
listPriorities: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listPriorities', args),
listAssignableUsers: (args: {
key: string
query?: string
siteId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('jira:listAssignableUsers', args),
searchUsers: (args?: { query?: string; siteId?: string }): Promise<unknown[]> =>
listAssignableUsers: (args: { key: string; query?: string; siteId?: string }) =>
ipcRenderer.invoke('jira:listAssignableUsers', args),
searchUsers: (args?: { query?: string; siteId?: string }) =>
ipcRenderer.invoke('jira:searchUsers', args),
listTransitions: (args: { key: string; siteId?: string }): Promise<unknown[]> =>
listTransitions: (args: { key: string; siteId?: string }) =>
ipcRenderer.invoke('jira:listTransitions', args),
getProjectStatusOrder: (args: {
projectKey: string
siteId?: string
}): Promise<JiraProjectStatusOrder> => ipcRenderer.invoke('jira:getProjectStatusOrder', args)
}
} satisfies PreloadApi['jira']
+2 -1
View File
@@ -1,5 +1,6 @@
import { ipcRenderer } from 'electron'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings'
import type { PreloadApi } from '../api-types'
export const keybindingsApi = {
get: (): Promise<KeybindingFileSnapshot> => ipcRenderer.invoke('keybindings:get'),
@@ -17,4 +18,4 @@ export const keybindingsApi = {
ipcRenderer.on('keybindings:changed', listener)
return () => ipcRenderer.removeListener('keybindings:changed', listener)
}
}
} satisfies PreloadApi['keybindings']
+23 -35
View File
@@ -1,37 +1,30 @@
import { ipcRenderer } from 'electron'
import type { LinearProjectDetail } from '../../shared/linear/project-types'
import type { PreloadApi } from '../api-types'
export const linearApi = {
connect: (args: {
apiKey: string
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:connect', args),
connect: (args: { apiKey: string }) => ipcRenderer.invoke('linear:connect', args),
disconnect: (args?: { workspaceId?: string }): Promise<void> =>
ipcRenderer.invoke('linear:disconnect', args),
selectWorkspace: (args: { workspaceId: string }): Promise<unknown> =>
selectWorkspace: (args: { workspaceId: string }) =>
ipcRenderer.invoke('linear:selectWorkspace', args),
status: (): Promise<unknown> => ipcRenderer.invoke('linear:status'),
status: () => ipcRenderer.invoke('linear:status'),
testConnection: (args?: {
workspaceId?: string
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
testConnection: (args?: { workspaceId?: string }) =>
ipcRenderer.invoke('linear:testConnection', args),
searchIssues: (args: {
query: string
limit?: number
workspaceId?: string
}): Promise<unknown[]> => ipcRenderer.invoke('linear:searchIssues', args),
searchIssues: (args: { query: string; limit?: number; workspaceId?: string }) =>
ipcRenderer.invoke('linear:searchIssues', args),
listIssues: (args?: {
filter?: 'assigned' | 'created' | 'all' | 'completed'
limit?: number
workspaceId?: string
attributeFilter?: unknown
}): Promise<unknown> => ipcRenderer.invoke('linear:listIssues', args),
}) => ipcRenderer.invoke('linear:listIssues', args),
createIssue: (args: {
teamId: string
@@ -49,7 +42,7 @@ export const linearApi = {
| { ok: false; error: string }
> => ipcRenderer.invoke('linear:createIssue', args),
getIssue: (args: { id: string; workspaceId?: string }): Promise<unknown> =>
getIssue: (args: { id: string; workspaceId?: string }) =>
ipcRenderer.invoke('linear:getIssue', args),
updateIssue: (args: {
@@ -66,18 +59,17 @@ export const linearApi = {
}): Promise<{ ok: true; id: string } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:addIssueComment', args),
issueComments: (args: { issueId: string; workspaceId?: string }): Promise<unknown[]> =>
issueComments: (args: { issueId: string; workspaceId?: string }) =>
ipcRenderer.invoke('linear:issueComments', args),
listTeams: (args?: { workspaceId?: string }): Promise<unknown[]> =>
ipcRenderer.invoke('linear:listTeams', args),
listTeams: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:listTeams', args),
listProjects: (args?: {
query?: string
limit?: number
workspaceId?: string
force?: boolean
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjects', args),
}) => ipcRenderer.invoke('linear:listProjects', args),
createProject: (args: {
name: string
@@ -94,7 +86,7 @@ export const linearApi = {
}): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> =>
ipcRenderer.invoke('linear:createProject', args),
getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise<unknown> =>
getProject: (args: { id: string; workspaceId: string; force?: boolean }) =>
ipcRenderer.invoke('linear:getProject', args),
listProjectIssues: (args: {
@@ -102,42 +94,38 @@ export const linearApi = {
limit?: number
workspaceId: string
force?: boolean
}): Promise<unknown> => ipcRenderer.invoke('linear:listProjectIssues', args),
}) => ipcRenderer.invoke('linear:listProjectIssues', args),
listCustomViews: (args: {
model: string
limit?: number
workspaceId?: string
force?: boolean
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViews', args),
}) => ipcRenderer.invoke('linear:listCustomViews', args),
getCustomView: (args: {
viewId: string
model: string
workspaceId: string
force?: boolean
}): Promise<unknown> => ipcRenderer.invoke('linear:getCustomView', args),
getCustomView: (args: { viewId: string; model: string; workspaceId: string; force?: boolean }) =>
ipcRenderer.invoke('linear:getCustomView', args),
listCustomViewIssues: (args: {
viewId: string
limit?: number
workspaceId: string
force?: boolean
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViewIssues', args),
}) => ipcRenderer.invoke('linear:listCustomViewIssues', args),
listCustomViewProjects: (args: {
viewId: string
limit?: number
workspaceId: string
force?: boolean
}): Promise<unknown> => ipcRenderer.invoke('linear:listCustomViewProjects', args),
}) => ipcRenderer.invoke('linear:listCustomViewProjects', args),
teamStates: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
teamStates: (args: { teamId: string; workspaceId?: string }) =>
ipcRenderer.invoke('linear:teamStates', args),
teamLabels: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
teamLabels: (args: { teamId: string; workspaceId?: string }) =>
ipcRenderer.invoke('linear:teamLabels', args),
teamMembers: (args: { teamId: string; workspaceId?: string }): Promise<unknown[]> =>
teamMembers: (args: { teamId: string; workspaceId?: string }) =>
ipcRenderer.invoke('linear:teamMembers', args)
}
} satisfies PreloadApi['linear']
+7 -4
View File
@@ -1,11 +1,14 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const macosTccPromptsApi = {
onThreshold: (callback: (payload: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void =>
onThreshold: (callback: (payload: { promptCount: number }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { promptCount: number }): void =>
callback(payload)
ipcRenderer.on('macosTccPrompts:threshold', listener)
return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener)
return (): void => {
ipcRenderer.removeListener('macosTccPrompts:threshold', listener)
}
},
consumePending: (): Promise<{ claimId: number; promptCount: number } | null> =>
ipcRenderer.invoke('macosTccPrompts:consumePending'),
@@ -14,4 +17,4 @@ export const macosTccPromptsApi = {
releasePending: (claimId: number): Promise<void> =>
ipcRenderer.invoke('macosTccPrompts:releasePending', claimId),
dismiss: (): Promise<void> => ipcRenderer.invoke('macosTccPrompts:dismiss')
}
} satisfies PreloadApi['macosTccPrompts']
+2 -1
View File
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron'
import type { MemorySnapshot } from '../../shared/process-stats-types'
import type { PreloadApi } from '../api-types'
export const memoryApi = {
getSnapshot: (): Promise<MemorySnapshot> => ipcRenderer.invoke('memory:getSnapshot')
}
} satisfies PreloadApi['memory']
@@ -1,4 +1,5 @@
import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
export const minimaxCredentialsApi = {
getStatus: (): Promise<{ configured: boolean }> =>
@@ -7,4 +8,4 @@ export const minimaxCredentialsApi = {
ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie),
clearCookie: (): Promise<{ configured: boolean }> =>
ipcRenderer.invoke('minimaxCredentials:clearCookie')
}
} satisfies PreloadApi['minimaxCredentials']
+2 -1
View File
@@ -3,6 +3,7 @@ import type { MobileRelayStatus } from '../../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure'
import type { PreloadApi } from '../api-types'
export const mobileApi = {
listNetworkInterfaces: (): Promise<{
@@ -92,4 +93,4 @@ export const mobileApi = {
ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener)
return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener)
}
}
} satisfies PreloadApi['mobile']

Some files were not shown because too many files have changed in this diff Show More