mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(cli): report which hosts a worktree listing covered, and stop the cap starving remote ones (#18417)
`orca worktree list` returned zero of 24 SSH worktrees at the default limit (#18104). Rows are resolved repo by repo, so every SSH repo's rows land contiguously at the end of the fleet order — the 24 remote rows sat at indices 496-520 of 521 and a plain `slice(0, 200)` never reached them. The omission was not fully silent: text output printed `truncated: showing 200 of 521` and JSON carried `totalCount` / `truncated`. What was missing is that the omission was *categorically every remote host* — no host column, no `hostScope`, nothing to distinguish "200 of 521" from "one host is entirely absent". Per docs/reference/ssh-execution-boundary.md, a listing that does not name its scope reads as absolute. Adopt the mechanism `terminal list` already has rather than inventing a second one: - `RuntimeTerminalListHostScope` becomes an alias of a shared `RuntimeListingHostScope`, now also carried (optional, so old hosts are unaffected) on `worktree.list` and `worktree.ps` results. - `src/shared/host-balanced-listing-page.ts` round-robins the row cap across hosts and returns the survivors in the caller's original relative order, so the page stays a subsequence of the unbounded listing and nothing downstream re-sorts. An uncapped listing is returned unchanged. - `worktree list` / `worktree ps` text output gains a `host=` column and the same trailing `scope:` line `terminal list` prints. Third defect, same mechanism: `hostScope.omittedHostIds` is built from the runtime's own bookkeeping, so it names `runtime:` ids for servers that are no longer paired — 6 of 9 in the recorded QA run hard-error when queried. Since `hostScope` is *the* documented way to complete a partial listing, that makes the mechanism unreliable for its intended use. Annotate rather than filter. Dropping an id would shrink what the listing admits it did not cover, and the boundary doc requires a listing to name its gaps — the gap is real whether or not this machine can name the host that owns it. `src/cli/omitted-host-scope-selectors.ts` resolves each omitted id against this machine's pairing store and the runtime's SSH-target registry and attaches the exact flag that reaches it, or `null` marked "not selectable from this machine". This is a client-side annotation: nothing new goes over the wire, it answers "can I select it" and never "is it up", and the SSH round trip is only paid when an `ssh:` host was actually omitted. No `--host` filter was added; the host column plus scope line covers the reported need without a new selector axis.
This commit is contained in:
@@ -32,6 +32,10 @@ import {
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import {
|
||||
annotateOmittedHostScope,
|
||||
type WithAnnotatedHostScope
|
||||
} from '../omitted-host-scope-selectors'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import {
|
||||
getBrowserWorktreeSelector,
|
||||
@@ -90,12 +94,16 @@ const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }
|
||||
|
||||
export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
'terminal list': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<RuntimeTerminalListResult>('terminal.list', {
|
||||
worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
|
||||
// Why: agent JSON calls dominate; topology stays available through an explicit opt-in.
|
||||
includeVisualLayouts: !json || flags.has('include-visual-layouts')
|
||||
})
|
||||
const result = await client.call<WithAnnotatedHostScope<RuntimeTerminalListResult>>(
|
||||
'terminal.list',
|
||||
{
|
||||
worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
|
||||
// Why: agent JSON calls dominate; topology stays available through an explicit opt-in.
|
||||
includeVisualLayouts: !json || flags.has('include-visual-layouts')
|
||||
}
|
||||
)
|
||||
await annotateOmittedHostScope(client, result.result)
|
||||
printResult(result, json, formatTerminalList)
|
||||
},
|
||||
'terminal show': async ({ flags, client, cwd, json }) => {
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format'
|
||||
import {
|
||||
annotateOmittedHostScope,
|
||||
type WithAnnotatedHostScope
|
||||
} from '../omitted-host-scope-selectors'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import {
|
||||
getOptionalNullableNumberFlag,
|
||||
@@ -171,16 +175,22 @@ async function getCreateRepoSelector(
|
||||
|
||||
export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
'worktree ps': async ({ flags, client, json }) => {
|
||||
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
const result = await client.call<WithAnnotatedHostScope<RuntimeWorktreePsResult>>(
|
||||
'worktree.ps',
|
||||
{ limit: getOptionalPositiveIntegerFlag(flags, 'limit') }
|
||||
)
|
||||
await annotateOmittedHostScope(client, result.result)
|
||||
printResult(result, json, formatWorktreePs)
|
||||
},
|
||||
'worktree list': async ({ flags, client, json }) => {
|
||||
const result = await client.call<RuntimeWorktreeListResult>('worktree.list', {
|
||||
repo: getOptionalStringFlag(flags, 'repo'),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
const result = await client.call<WithAnnotatedHostScope<RuntimeWorktreeListResult>>(
|
||||
'worktree.list',
|
||||
{
|
||||
repo: getOptionalStringFlag(flags, 'repo'),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
}
|
||||
)
|
||||
await annotateOmittedHostScope(client, result.result)
|
||||
printResult(result, json, formatWorktreeList)
|
||||
},
|
||||
'worktree show': async ({ flags, client, cwd, json }) => {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
const TERMINAL_ROW = {
|
||||
handle: 'term_1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo::/wt',
|
||||
worktreePath: '/wt',
|
||||
branch: 'main',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'worker',
|
||||
connected: true,
|
||||
writable: true,
|
||||
lastOutputAt: null,
|
||||
preview: '',
|
||||
executionHostId: 'local'
|
||||
}
|
||||
|
||||
describe('omittedHostIds selector annotation', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('marks a stale runtime host that no caller can select', async () => {
|
||||
// Why: `omittedHostIds` is built from the runtime's own bookkeeping, so it names `runtime:`
|
||||
// ids for servers that are no longer paired. An agent looping over the list to complete a
|
||||
// partial listing hard-errors on those — 6 of 9 in the recorded QA run.
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'env-paired', 'm4air')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [TERMINAL_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: {
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['runtime:env-paired', 'runtime:env-retired']
|
||||
}
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
|
||||
expect(printed.result.hostScope.omittedHostIds).toEqual([
|
||||
'runtime:env-paired',
|
||||
'runtime:env-retired'
|
||||
])
|
||||
expect(printed.result.hostScope.omittedHostSelectors).toEqual([
|
||||
{ hostId: 'runtime:env-paired', selector: '--environment m4air' },
|
||||
{ hostId: 'runtime:env-retired', selector: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('says which omitted hosts are not selectable in the human listing', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'env-paired', 'm4air')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [TERMINAL_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: {
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['runtime:env-paired', 'runtime:env-retired']
|
||||
}
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list'], '/tmp/repo')
|
||||
|
||||
const printed = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(printed).toContain('runtime:env-paired (--environment m4air)')
|
||||
expect(printed).toContain('runtime:env-retired (not selectable from this machine)')
|
||||
})
|
||||
|
||||
it('resolves an omitted SSH host against the targets the runtime actually knows', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([])
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [TERMINAL_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1', 'ssh:box-gone'] }
|
||||
}),
|
||||
okFixture('req_ssh_targets', { targets: [{ id: 'box-1', label: 'openclaw' }] })
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
|
||||
expect(printed.result.hostScope.omittedHostSelectors).toEqual([
|
||||
{ hostId: 'ssh:box-1', selector: '--host ssh:box-1' },
|
||||
{ hostId: 'ssh:box-gone', selector: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('never keeps a host id out of omittedHostIds', async () => {
|
||||
// Why: filtering the unreachable ones would shrink what the listing admits it did not cover.
|
||||
// The gap is real whether or not this machine can name the host that owns it.
|
||||
listEnvironmentsMock.mockReturnValue([])
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [],
|
||||
totalCount: 0,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [], omittedHostIds: ['runtime:env-retired'] }
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
|
||||
expect(printed.result.hostScope.omittedHostIds).toEqual(['runtime:env-retired'])
|
||||
})
|
||||
|
||||
it('costs no extra round trip when nothing was omitted', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_terminal_list', {
|
||||
terminals: [TERMINAL_ROW],
|
||||
totalCount: 1,
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worktree listings report their host coverage', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('prints a host column and the scope line for `worktree list`', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([])
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_worktree_list', {
|
||||
worktrees: [
|
||||
{
|
||||
id: 'repo-ssh::/remote/wt',
|
||||
branch: 'main',
|
||||
path: '/remote/wt',
|
||||
hostId: 'ssh:box-1',
|
||||
displayName: 'remote',
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
linkedIssue: null,
|
||||
comment: ''
|
||||
}
|
||||
],
|
||||
totalCount: 521,
|
||||
truncated: true,
|
||||
hostScope: { hostIds: ['ssh:box-1'], omittedHostIds: ['runtime:env-retired'] }
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['worktree', 'list'], '/tmp/repo')
|
||||
|
||||
const printed = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(printed).toContain('host=ssh:box-1')
|
||||
expect(printed).toContain('scope: ssh:box-1')
|
||||
expect(printed).toContain('runtime:env-retired (not selectable from this machine)')
|
||||
expect(printed).toContain('truncated: showing 1 of 521')
|
||||
})
|
||||
|
||||
it('does not claim a scope for `worktree ps` when the host reported none', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_worktree_ps', { worktrees: [], totalCount: 0, truncated: false })
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['worktree', 'ps'], '/tmp/repo')
|
||||
|
||||
const printed = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(printed).toContain('scope: unverifiable')
|
||||
})
|
||||
})
|
||||
@@ -88,7 +88,10 @@ describe('orca terminal list host scope', () => {
|
||||
expect(printed.result.terminals[0].executionHostId).toBe('ssh:box-1')
|
||||
expect(printed.result.hostScope).toEqual({
|
||||
hostIds: ['ssh:box-1'],
|
||||
omittedHostIds: ['local']
|
||||
omittedHostIds: ['local'],
|
||||
// The CLI annotates each omitted host with the flag that reaches it; see
|
||||
// index-omitted-host-scope-selectors.test.ts.
|
||||
omittedHostSelectors: [{ hostId: 'local', selector: '--host local' }]
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId,
|
||||
type ParsedExecutionHost
|
||||
} from '../shared/execution-host'
|
||||
import type { RuntimeListingHostScope } from '../shared/runtime-listing-host-scope'
|
||||
import {
|
||||
findEnvironmentByName,
|
||||
findSshTargetByName,
|
||||
listSshTargets,
|
||||
type SshTargetSummary
|
||||
} from './host-selector-alternatives'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
|
||||
export type OmittedHostScopeSelector = {
|
||||
hostId: ExecutionHostId
|
||||
/** The flag that routes a follow-up query to this host, or null when it names nothing here. */
|
||||
selector: string | null
|
||||
}
|
||||
|
||||
/** A host scope annotated on this machine. The runtime never sends `omittedHostSelectors`. */
|
||||
export type ListingHostScopeWithSelectors = RuntimeListingHostScope & {
|
||||
omittedHostSelectors?: OmittedHostScopeSelector[]
|
||||
}
|
||||
|
||||
export type WithAnnotatedHostScope<TResult> = Omit<TResult, 'hostScope'> & {
|
||||
hostScope?: ListingHostScopeWithSelectors
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves how to reach each host a listing did not cover.
|
||||
*
|
||||
* `hostScope` is the documented way to complete a partial listing, but `omittedHostIds` is built
|
||||
* from the runtime's own bookkeeping — repos, folder workspaces, and workspace sessions — so it
|
||||
* names `runtime:` ids for servers that are no longer paired. An agent looping over the list to
|
||||
* finish the job hard-errors on those.
|
||||
*
|
||||
* The ids are kept rather than filtered: dropping one would shrink what the listing admits it did
|
||||
* not cover, and `docs/reference/ssh-execution-boundary.md` requires a listing to name its gaps.
|
||||
* A `null` selector marks the ones this machine cannot name, which is the part a caller needs.
|
||||
* Only the local pairing store and SSH-target registry are consulted, so this answers "can I
|
||||
* select it", never "is it up" — no host is claimed live or exited on this path.
|
||||
*/
|
||||
export async function resolveOmittedHostScopeSelectors(
|
||||
client: RuntimeClient,
|
||||
omittedHostIds: readonly ExecutionHostId[]
|
||||
): Promise<OmittedHostScopeSelector[]> {
|
||||
const parsed = omittedHostIds.map((hostId) => ({
|
||||
hostId,
|
||||
host: parseExecutionHostId(hostId)
|
||||
}))
|
||||
const environments = parsed.some((entry) => entry.host?.kind === 'runtime')
|
||||
? await listPairedEnvironments()
|
||||
: []
|
||||
// Why: SSH targets need a round trip, so only pay for it when an ssh host was actually omitted.
|
||||
const sshTargets = parsed.some((entry) => entry.host?.kind === 'ssh')
|
||||
? await listSshTargets(client)
|
||||
: []
|
||||
return parsed.map(({ hostId, host }) => ({
|
||||
hostId,
|
||||
selector: resolveSelector(host, environments, sshTargets)
|
||||
}))
|
||||
}
|
||||
|
||||
async function listPairedEnvironments(): Promise<{ id: string; name: string }[]> {
|
||||
const [{ listEnvironments }, { getDefaultUserDataPath }] = await Promise.all([
|
||||
import('./runtime/environments.js'),
|
||||
import('./runtime-client.js')
|
||||
])
|
||||
return listEnvironments(getDefaultUserDataPath()).map((environment) => ({
|
||||
id: environment.id,
|
||||
name: environment.name
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveSelector(
|
||||
host: ParsedExecutionHost | null,
|
||||
environments: readonly { id: string; name: string }[],
|
||||
sshTargets: readonly SshTargetSummary[]
|
||||
): string | null {
|
||||
if (host?.kind === 'local') {
|
||||
return '--host local'
|
||||
}
|
||||
if (host?.kind === 'ssh') {
|
||||
return findSshTargetByName(sshTargets, host.targetId) ? `--host ssh:${host.targetId}` : null
|
||||
}
|
||||
if (host?.kind === 'runtime') {
|
||||
const environment = findEnvironmentByName(environments, host.environmentId)
|
||||
return environment ? `--environment ${environment.name}` : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Renders a scope line; an absent scope means the host never reported one, not full coverage. */
|
||||
export function formatListingHostScope(scope: ListingHostScopeWithSelectors | undefined): string {
|
||||
if (!scope) {
|
||||
return 'scope: unverifiable — this host does not report which hosts it lists'
|
||||
}
|
||||
const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none'
|
||||
if (scope.omittedHostIds.length === 0) {
|
||||
return `scope: ${covered}`
|
||||
}
|
||||
const selectorByHostId = new Map(
|
||||
(scope.omittedHostSelectors ?? []).map((entry) => [entry.hostId, entry.selector])
|
||||
)
|
||||
const omitted = scope.omittedHostIds.map((hostId) => {
|
||||
if (!selectorByHostId.has(hostId)) {
|
||||
return hostId
|
||||
}
|
||||
const selector = selectorByHostId.get(hostId)
|
||||
return selector ? `${hostId} (${selector})` : `${hostId} (not selectable from this machine)`
|
||||
})
|
||||
return `scope: ${covered} — not covered: ${omitted.join(', ')}`
|
||||
}
|
||||
|
||||
/** Attaches the resolved selectors in place; a listing with no omitted hosts pays nothing. */
|
||||
export async function annotateOmittedHostScope(
|
||||
client: RuntimeClient,
|
||||
result: { hostScope?: ListingHostScopeWithSelectors }
|
||||
): Promise<void> {
|
||||
const scope = result.hostScope
|
||||
if (!scope || scope.omittedHostIds.length === 0) {
|
||||
return
|
||||
}
|
||||
scope.omittedHostSelectors = await resolveOmittedHostScopeSelectors(client, scope.omittedHostIds)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
import { WORKTREE_LISTING_SCOPE_NOTES } from './worktree-listing-scope-notes'
|
||||
import { SERVE_COMMAND_SPECS } from './serve'
|
||||
import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close'
|
||||
|
||||
@@ -65,7 +66,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'list'],
|
||||
summary: 'List Orca-managed worktrees',
|
||||
usage: 'orca worktree list [--repo <selector>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit']
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'repo', 'limit'],
|
||||
notes: [...WORKTREE_LISTING_SCOPE_NOTES]
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'show'],
|
||||
@@ -180,7 +182,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'ps'],
|
||||
summary: 'Show a compact orchestration summary across worktrees',
|
||||
usage: 'orca worktree ps [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit']
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit'],
|
||||
notes: [...WORKTREE_LISTING_SCOPE_NOTES]
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'list'],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Shared by `worktree list` and `worktree ps`, which report host coverage the same way. */
|
||||
export const WORKTREE_LISTING_SCOPE_NOTES: readonly string[] = [
|
||||
'Each row carries the execution host that owns it (`host=`), and the trailing `scope:` line names every host the page covers plus the ones it does not.',
|
||||
'A host named under `not covered` may still have workspaces; an empty answer for it is not evidence that it has none. Each is annotated with the flag that reaches it, or marked not selectable from this machine.',
|
||||
'The row cap is shared across hosts, so a host whose rows sort last is not starved out of the page.'
|
||||
]
|
||||
@@ -1,10 +1,10 @@
|
||||
import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict'
|
||||
import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy'
|
||||
import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors'
|
||||
import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalCreate,
|
||||
RuntimeTerminalFocus,
|
||||
RuntimeTerminalListHostScope,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalVisualLayout,
|
||||
RuntimeTerminalVisualLayoutNode,
|
||||
@@ -18,8 +18,10 @@ import type {
|
||||
RuntimeTerminalWait
|
||||
} from '../shared/runtime-types'
|
||||
|
||||
export function formatTerminalList(result: RuntimeTerminalListResult): string {
|
||||
const scope = formatTerminalListHostScope(result.hostScope)
|
||||
export function formatTerminalList(
|
||||
result: WithAnnotatedHostScope<RuntimeTerminalListResult>
|
||||
): string {
|
||||
const scope = formatListingHostScope(result.hostScope)
|
||||
if (result.terminals.length === 0) {
|
||||
return `No terminals listed.\n${scope}`
|
||||
}
|
||||
@@ -37,18 +39,6 @@ export function formatTerminalList(result: RuntimeTerminalListResult): string {
|
||||
: bodyWithScope
|
||||
}
|
||||
|
||||
// Why: a listing that does not say what it covers reads as absolute, and an
|
||||
// absent scope means the host is too old to know — not that it covered everything.
|
||||
function formatTerminalListHostScope(scope: RuntimeTerminalListHostScope | undefined): string {
|
||||
if (!scope) {
|
||||
return 'scope: unverifiable — this host does not report which hosts it lists'
|
||||
}
|
||||
const covered = scope.hostIds.length > 0 ? scope.hostIds.join(', ') : 'none'
|
||||
const omitted =
|
||||
scope.omittedHostIds.length > 0 ? ` — not covered: ${scope.omittedHostIds.join(', ')}` : ''
|
||||
return `scope: ${covered}${omitted}`
|
||||
}
|
||||
|
||||
function formatTerminalVisualLayouts(
|
||||
layouts: readonly RuntimeTerminalVisualLayout[] | undefined
|
||||
): string | null {
|
||||
|
||||
+17
-10
@@ -7,6 +7,7 @@ import type {
|
||||
RuntimeWorktreeRecord
|
||||
} from '../shared/runtime-types'
|
||||
import type { MemorySnapshot, WorktreeMemory } from '../shared/process-stats-types'
|
||||
import { formatListingHostScope, type WithAnnotatedHostScope } from './omitted-host-scope-selectors'
|
||||
|
||||
export function formatMemorySnapshot(snapshot: MemorySnapshot): string {
|
||||
const topWorktrees = [...snapshot.worktrees].sort((a, b) => b.memory - a.memory).slice(0, 10)
|
||||
@@ -130,19 +131,21 @@ export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): s
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatWorktreePs(result: RuntimeWorktreePsResult): string {
|
||||
export function formatWorktreePs(result: WithAnnotatedHostScope<RuntimeWorktreePsResult>): string {
|
||||
const scope = formatListingHostScope(result.hostScope)
|
||||
if (result.worktrees.length === 0) {
|
||||
return 'No worktrees found.'
|
||||
return `No worktrees found.\n${scope}`
|
||||
}
|
||||
const body = result.worktrees
|
||||
.map(
|
||||
(worktree) =>
|
||||
`${worktree.repo} ${worktree.branch} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}`
|
||||
`${worktree.repo} ${worktree.branch} host=${worktree.hostId ?? 'unverifiable'} live:${worktree.liveTerminalCount} pty:${worktree.hasAttachedPty ? 'yes' : 'no'} unread:${worktree.unread ? 'yes' : 'no'}\n${worktree.path}${worktree.preview ? `\npreview: ${worktree.preview}` : ''}`
|
||||
)
|
||||
.join('\n\n')
|
||||
const bodyWithScope = `${body}\n\n${scope}`
|
||||
return result.truncated
|
||||
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
||||
: body
|
||||
? `${bodyWithScope}\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
||||
: bodyWithScope
|
||||
}
|
||||
|
||||
export function formatRepoList(result: RuntimeRepoList): string {
|
||||
@@ -168,19 +171,23 @@ export function formatRepoRefs(result: RuntimeRepoSearchRefs): string {
|
||||
return result.truncated ? `${result.refs.join('\n')}\n\ntruncated: yes` : result.refs.join('\n')
|
||||
}
|
||||
|
||||
export function formatWorktreeList(result: RuntimeWorktreeListResult): string {
|
||||
export function formatWorktreeList(
|
||||
result: WithAnnotatedHostScope<RuntimeWorktreeListResult>
|
||||
): string {
|
||||
const scope = formatListingHostScope(result.hostScope)
|
||||
if (result.worktrees.length === 0) {
|
||||
return 'No worktrees found.'
|
||||
return `No worktrees found.\n${scope}`
|
||||
}
|
||||
const body = result.worktrees
|
||||
.map((worktree) => {
|
||||
const childCount = worktree.childWorktreeIds?.length ?? 0
|
||||
return `${String(worktree.id)} ${String(worktree.branch)} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
|
||||
return `${String(worktree.id)} ${String(worktree.branch)} host=${String(worktree.hostId ?? 'unverifiable')} ${String(worktree.path)}\ndisplayName: ${String(worktree.displayName ?? '')}\nparentWorktreeId: ${String(worktree.parentWorktreeId ?? 'null')}\nchildWorktreeIds: ${childCount > 0 ? worktree.childWorktreeIds.join(',') : '[]'}\nlinkedIssue: ${String(worktree.linkedIssue ?? 'null')}\ncomment: ${String(worktree.comment ?? '')}`
|
||||
})
|
||||
.join('\n\n')
|
||||
const bodyWithScope = `${body}\n\n${scope}`
|
||||
return result.truncated
|
||||
? `${body}\n\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
||||
: body
|
||||
? `${bodyWithScope}\ntruncated: showing ${result.worktrees.length} of ${result.totalCount}`
|
||||
: bodyWithScope
|
||||
}
|
||||
|
||||
export function formatWorktreeShow(result: { worktree: RuntimeWorktreeRecord }): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner'
|
||||
import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude'
|
||||
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import type { RuntimeWorktreePsResult } from '../../shared/runtime-types'
|
||||
import { buildRuntimeWorktreePsSummaries } from './runtime-worktree-ps-summaries'
|
||||
import { buildRuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identit
|
||||
import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime'
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { buildWorktreeListingPage } from './worktree-listing-host-scope'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
@@ -30,11 +31,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
|
||||
async getWorktreePs(
|
||||
limit = DEFAULT_WORKTREE_PS_LIMIT,
|
||||
sourceDefaultsSupported = true
|
||||
): Promise<{
|
||||
worktrees: RuntimeWorktreePsSummary[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}> {
|
||||
): Promise<RuntimeWorktreePsResult> {
|
||||
if (!Number.isInteger(limit) || limit <= 0) {
|
||||
throw new Error('invalid_limit')
|
||||
}
|
||||
@@ -111,11 +108,9 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
|
||||
})
|
||||
|
||||
const sorted = [...summaries.values()].sort(compareWorktreePs)
|
||||
return {
|
||||
worktrees: sorted.slice(0, limit),
|
||||
totalCount: sorted.length,
|
||||
truncated: sorted.length > limit
|
||||
}
|
||||
// Why: the same cap starvation as worktree.list — a host whose rows all sort last gets no
|
||||
// page at all, which is indistinguishable from it having no workspaces (#18104).
|
||||
return buildWorktreeListingPage(sorted, limit, this.listKnownExecutionHostIds())
|
||||
}
|
||||
|
||||
listRepos(): Repo[] {
|
||||
|
||||
@@ -136,7 +136,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId
|
||||
listResolved: () => this.listResolvedWorktrees(),
|
||||
resolveRepo: (selector) => this.resolveRepoSelector(selector),
|
||||
selectRepos: (selector) => this.selectReposBySelector(selector),
|
||||
scanRepo: (repo) => this.listRepoWorktreesForResolution(repo)
|
||||
scanRepo: (repo) => this.listRepoWorktreesForResolution(repo),
|
||||
listKnownHostIds: () => this.listKnownExecutionHostIds()
|
||||
})
|
||||
|
||||
protected readonly ptyForegroundAgent = new RuntimePtyForegroundAgent({
|
||||
|
||||
@@ -168,6 +168,8 @@ describe('OrcaRuntimeService', () => {
|
||||
agents: []
|
||||
}
|
||||
],
|
||||
// Why: the summary now names the hosts it covered; an absent scope would read as absolute.
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] },
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
})
|
||||
|
||||
@@ -44,7 +44,8 @@ function queries(
|
||||
listResolved: async () => [],
|
||||
resolveRepo: async () => repo,
|
||||
selectRepos: () => [repo],
|
||||
scanRepo: async () => ({ ok, worktrees: [...worktrees] })
|
||||
scanRepo: async () => ({ ok, worktrees: [...worktrees] }),
|
||||
listKnownHostIds: () => []
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ function queries(store: RuntimeStore): RuntimeManagedWorktreeQueries {
|
||||
listResolved: async () => [],
|
||||
resolveRepo: async () => store.getRepos()[0]!,
|
||||
selectRepos: () => store.getRepos(),
|
||||
scanRepo: async () => ({ ok: true, worktrees: [] })
|
||||
scanRepo: async () => ({ ok: true, worktrees: [] }),
|
||||
listKnownHostIds: () => []
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { DetectedWorktreeListResult, Worktree } from '../../shared/worktree/types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { RuntimeWorktreeListResult } from '../../shared/runtime-types'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { buildWorktreeListingPage } from './worktree-listing-host-scope'
|
||||
import { readWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
@@ -38,6 +39,8 @@ type Dependencies = {
|
||||
resolveRepo(selector: string): Promise<Repo>
|
||||
selectRepos(selector: string): Repo[]
|
||||
scanRepo(repo: Repo): Promise<RuntimeWorktreeScanResult>
|
||||
/** Hosts this runtime has repos or workspaces on, so a host with no rows is still named. */
|
||||
listKnownHostIds(): Iterable<ExecutionHostId>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,11 +103,9 @@ export class RuntimeManagedWorktreeQueries {
|
||||
(!repoId || worktree.repoId === repoId) &&
|
||||
this.isVisible(worktree, matchers.get(worktree.repoId), sourceDefaultsSupported)
|
||||
)
|
||||
return {
|
||||
worktrees: worktrees.slice(0, limit),
|
||||
totalCount: worktrees.length,
|
||||
truncated: worktrees.length > limit
|
||||
}
|
||||
// Why: a `--repo` listing was scoped by the caller, so naming every configured host as
|
||||
// omitted would report a gap the caller deliberately excluded.
|
||||
return buildWorktreeListingPage(worktrees, limit, repoId ? [] : this.deps.listKnownHostIds())
|
||||
}
|
||||
|
||||
resolveRepoForConnection(selector: string, connectionId?: string | null): Promise<Repo> {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page'
|
||||
import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
const LOCAL_REPO: Repo = {
|
||||
id: 'repo-local',
|
||||
path: '/workspace/app',
|
||||
displayName: 'app',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
const SSH_REPO: Repo = {
|
||||
...LOCAL_REPO,
|
||||
id: 'repo-ssh',
|
||||
connectionId: 'box-1',
|
||||
displayName: 'app (remote)'
|
||||
}
|
||||
|
||||
const settings = {
|
||||
workspaceDir: '/worktrees',
|
||||
nestWorkspaces: true,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
}
|
||||
|
||||
function worktree(repoId: string, path: string, hostId: string): ResolvedWorktree {
|
||||
return {
|
||||
id: `${repoId}::${path}`,
|
||||
repoId,
|
||||
path,
|
||||
branch: 'main',
|
||||
hostId,
|
||||
displayName: path,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git: { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: false }
|
||||
} as unknown as ResolvedWorktree
|
||||
}
|
||||
|
||||
/** The reproduced shape from #18104: every remote row lands contiguously at the end. */
|
||||
function fleet(localCount: number, sshCount: number): ResolvedWorktree[] {
|
||||
return [
|
||||
...Array.from({ length: localCount }, (_, index) =>
|
||||
worktree(LOCAL_REPO.id, `/worktrees/local-${index}`, 'local')
|
||||
),
|
||||
...Array.from({ length: sshCount }, (_, index) =>
|
||||
worktree(SSH_REPO.id, `/remote/wt-${index}`, 'ssh:box-1')
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function queries(
|
||||
resolved: ResolvedWorktree[],
|
||||
knownHostIds: ExecutionHostId[] = ['local', 'ssh:box-1']
|
||||
): RuntimeManagedWorktreeQueries {
|
||||
const store = {
|
||||
getRepos: () => [LOCAL_REPO, SSH_REPO],
|
||||
getRepo: () => LOCAL_REPO,
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getSettings: () => settings
|
||||
} as unknown as RuntimeStore
|
||||
return new RuntimeManagedWorktreeQueries({
|
||||
getStore: () => store,
|
||||
listResolved: async () => resolved,
|
||||
resolveRepo: async () => SSH_REPO,
|
||||
selectRepos: () => [SSH_REPO],
|
||||
scanRepo: async () => ({ ok: true, worktrees: [] }),
|
||||
listKnownHostIds: () => knownHostIds
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree.list host coverage under the row cap', () => {
|
||||
it('returns remote rows that sit entirely past the cap', async () => {
|
||||
// Why #18104: 497 local + 24 SSH rows, SSH at indices 496-520, and a 200-row cap returned
|
||||
// `{local: 200}` — zero of 24 remote worktrees, with nothing saying the gap was a whole host.
|
||||
const result = await queries(fleet(497, 24)).list(undefined, 200)
|
||||
|
||||
expect(result.totalCount).toBe(521)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.worktrees).toHaveLength(200)
|
||||
const remote = result.worktrees.filter((row) => row.hostId === 'ssh:box-1')
|
||||
expect(remote).toHaveLength(24)
|
||||
expect(result.hostScope).toEqual({ hostIds: ['local', 'ssh:box-1'], omittedHostIds: [] })
|
||||
})
|
||||
|
||||
it('keeps the page a subsequence of the unbounded listing', async () => {
|
||||
// Why: balancing decides which rows survive the cap, never how the survivors are ordered.
|
||||
const resolved = fleet(497, 24)
|
||||
const result = await queries(resolved).list(undefined, 200)
|
||||
|
||||
const positions = result.worktrees.map((row) => resolved.findIndex((it) => it.id === row.id))
|
||||
expect(positions).toEqual([...positions].sort((left, right) => left - right))
|
||||
})
|
||||
|
||||
it('names a configured host that contributed no rows at all', async () => {
|
||||
// Why: a repo whose scan failed contributes zero rows exactly like a host with no worktrees.
|
||||
// docs/reference/ssh-execution-boundary.md forbids the listing from reading as absolute there.
|
||||
const result = await queries(fleet(3, 0), ['local', 'ssh:box-1', 'runtime:paired']).list(
|
||||
undefined,
|
||||
200
|
||||
)
|
||||
|
||||
expect(result.hostScope).toEqual({
|
||||
hostIds: ['local'],
|
||||
omittedHostIds: ['runtime:paired', 'ssh:box-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report configured hosts as omitted from a --repo listing', async () => {
|
||||
// Why: the caller scoped this themselves, so naming the hosts they excluded is noise.
|
||||
const result = await queries(fleet(0, 5)).list('id:repo-ssh', 200)
|
||||
|
||||
expect(result.hostScope).toEqual({ hostIds: ['ssh:box-1'], omittedHostIds: [] })
|
||||
})
|
||||
|
||||
it('leaves an uncapped listing byte-identical', async () => {
|
||||
const resolved = fleet(4, 2)
|
||||
const result = await queries(resolved).list(undefined, 200)
|
||||
|
||||
expect(result.worktrees.map((row) => row.id)).toEqual(resolved.map((row) => row.id))
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectHostBalancedPage', () => {
|
||||
it('gives every host a share of the cap rather than filling it from the first', () => {
|
||||
const rows = [
|
||||
...Array.from({ length: 10 }, (_, index) => ({ host: 'local', index })),
|
||||
...Array.from({ length: 10 }, (_, index) => ({ host: 'ssh:box-1', index: index + 10 }))
|
||||
]
|
||||
|
||||
const page = selectHostBalancedPage(rows, 4, (row) => row.host)
|
||||
|
||||
expect(page.map((row) => row.host)).toEqual(['local', 'local', 'ssh:box-1', 'ssh:box-1'])
|
||||
})
|
||||
|
||||
it('fills the cap from the remaining hosts when one runs out of rows', () => {
|
||||
const rows = [
|
||||
{ host: 'local', id: 'a' },
|
||||
{ host: 'local', id: 'b' },
|
||||
{ host: 'local', id: 'c' },
|
||||
{ host: 'ssh:box-1', id: 'd' }
|
||||
]
|
||||
|
||||
const page = selectHostBalancedPage(rows, 3, (row) => row.host)
|
||||
|
||||
expect(page.map((row) => row.id)).toEqual(['a', 'b', 'd'])
|
||||
})
|
||||
|
||||
it('buckets rows with no host together instead of dropping them', () => {
|
||||
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
|
||||
|
||||
expect(selectHostBalancedPage(rows, 2, () => undefined).map((row) => row.id)).toEqual([
|
||||
'a',
|
||||
'b'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { selectHostBalancedPage } from '../../shared/host-balanced-listing-page'
|
||||
import type { RuntimeListingHostScope } from '../../shared/runtime-listing-host-scope'
|
||||
|
||||
/**
|
||||
* Applies a worktree listing's row cap and reports which hosts the resulting page covers.
|
||||
*
|
||||
* Rows are resolved repo by repo, so every SSH repo's rows land contiguously at the end of the
|
||||
* fleet order: 24 remote worktrees sat at indices 496-520 of 521 and a 200-row cap returned zero
|
||||
* of them (#18104). Balancing the page across hosts fixes the starvation; the scope is what makes
|
||||
* the remaining gap legible, because a host with no rows in the page is otherwise indistinguishable
|
||||
* from a host with no worktrees — which `docs/reference/ssh-execution-boundary.md` forbids a
|
||||
* listing from implying.
|
||||
*/
|
||||
export function buildWorktreeListingPage<TRow extends { hostId?: ExecutionHostId }>(
|
||||
rows: readonly TRow[],
|
||||
limit: number,
|
||||
knownHostIds: Iterable<ExecutionHostId>
|
||||
): {
|
||||
worktrees: TRow[]
|
||||
hostScope: RuntimeListingHostScope
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
} {
|
||||
const page = selectHostBalancedPage(rows, limit, (row) => row.hostId)
|
||||
return {
|
||||
worktrees: page,
|
||||
hostScope: buildWorktreeListingHostScope({
|
||||
pageHostIds: page.map((row) => row.hostId),
|
||||
matchedHostIds: rows.map((row) => row.hostId),
|
||||
knownHostIds
|
||||
}),
|
||||
totalCount: rows.length,
|
||||
truncated: rows.length > limit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The worktree-listing counterpart of `buildTerminalListHostScope`: names the hosts the returned
|
||||
* page covers, and every host it does not — including a configured repo whose scan failed, which
|
||||
* contributes zero rows exactly like a host with no worktrees.
|
||||
*/
|
||||
export function buildWorktreeListingHostScope(args: {
|
||||
/** Hosts of the rows actually returned. */
|
||||
pageHostIds: Iterable<ExecutionHostId | undefined>
|
||||
/** Hosts of every row that matched, including those the cap dropped. */
|
||||
matchedHostIds: Iterable<ExecutionHostId | undefined>
|
||||
/** Hosts this runtime has configured repos or workspaces on, even if they contributed no rows. */
|
||||
knownHostIds: Iterable<ExecutionHostId>
|
||||
}): RuntimeListingHostScope {
|
||||
const covered = new Set<ExecutionHostId>()
|
||||
for (const hostId of args.pageHostIds) {
|
||||
if (hostId) {
|
||||
covered.add(hostId)
|
||||
}
|
||||
}
|
||||
const omitted = new Set<ExecutionHostId>()
|
||||
for (const hostId of [...args.matchedHostIds, ...args.knownHostIds]) {
|
||||
if (hostId && !covered.has(hostId)) {
|
||||
omitted.add(hostId)
|
||||
}
|
||||
}
|
||||
return { hostIds: [...covered].sort(), omittedHostIds: [...omitted].sort() }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
const ipcMain = {
|
||||
on: vi.fn(() => ipcMain),
|
||||
removeListener: vi.fn(() => ipcMain),
|
||||
emit: vi.fn(() => true)
|
||||
}
|
||||
return {
|
||||
BrowserWindow: { fromId: vi.fn((): unknown => null) },
|
||||
webContents: { fromId: vi.fn((): unknown => null) },
|
||||
ipcMain,
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}
|
||||
})
|
||||
vi.mock('electron', () => electronMocks)
|
||||
|
||||
const getSshGitProviderMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: vi.fn(() => 0),
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable',
|
||||
requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId)
|
||||
}))
|
||||
|
||||
const listWorktreesStrictMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../git/worktree', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listWorktreesStrict: listWorktreesStrictMock
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const LOCAL_REPO_ID = 'repo-local'
|
||||
const LOCAL_REPO_PATH = '/Users/me/dev/app'
|
||||
const SSH_REPO_ID = 'repo-ssh'
|
||||
const SSH_REPO_PATH = '/home/user/app'
|
||||
const SSH_CONNECTION_ID = 'box-1'
|
||||
|
||||
function gitWorktree(path: string, isMain = false) {
|
||||
return { path, head: 'abc', branch: 'main', isBare: false, isMainWorktree: isMain }
|
||||
}
|
||||
|
||||
/** Local rows sort ahead of the remote ones, mirroring the fleet order that starves the cap. */
|
||||
function makeStore() {
|
||||
const metaById: Record<string, unknown> = {}
|
||||
return {
|
||||
getRepo: (id: string) =>
|
||||
makeStore()
|
||||
.getRepos()
|
||||
.find((repo) => repo.id === id),
|
||||
getRepos: () => [
|
||||
{
|
||||
id: LOCAL_REPO_ID,
|
||||
path: LOCAL_REPO_PATH,
|
||||
displayName: 'app',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
},
|
||||
{
|
||||
id: SSH_REPO_ID,
|
||||
path: SSH_REPO_PATH,
|
||||
displayName: 'app remote',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 2,
|
||||
connectionId: SSH_CONNECTION_ID
|
||||
}
|
||||
],
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (id: string) => metaById[id],
|
||||
setWorktreeMeta: (id: string, meta: Record<string, unknown>) => {
|
||||
metaById[id] = { ...(metaById[id] as object), ...meta }
|
||||
return metaById[id]
|
||||
},
|
||||
removeWorktreeMeta: () => {},
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getAllWorkspaceLineage: () => ({}),
|
||||
removeWorktreeLineage: vi.fn(),
|
||||
removeWorkspaceLineage: vi.fn(),
|
||||
getGitHubCache: () => undefined as never,
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/tmp/workspaces',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: 'none',
|
||||
branchPrefixCustom: ''
|
||||
}),
|
||||
getProjects: () => []
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree.ps host coverage', () => {
|
||||
beforeEach(() => {
|
||||
getSshGitProviderMock.mockReset()
|
||||
listWorktreesStrictMock.mockReset()
|
||||
listWorktreesStrictMock.mockResolvedValue([
|
||||
gitWorktree(LOCAL_REPO_PATH, true),
|
||||
gitWorktree(`${LOCAL_REPO_PATH}-a`),
|
||||
gitWorktree(`${LOCAL_REPO_PATH}-b`),
|
||||
gitWorktree(`${LOCAL_REPO_PATH}-c`)
|
||||
])
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
listWorktrees: vi.fn(async () => [
|
||||
gitWorktree(SSH_REPO_PATH, true),
|
||||
gitWorktree(`${SSH_REPO_PATH}-a`)
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('names every host the page covers', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
const result = await runtime.getWorktreePs(10_000)
|
||||
|
||||
expect(result.hostScope?.hostIds).toEqual(['local', `ssh:${SSH_CONNECTION_ID}`])
|
||||
expect(result.hostScope?.omittedHostIds).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a remote row in the page when the cap cannot hold every local row', async () => {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
|
||||
const result = await runtime.getWorktreePs(2)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.worktrees).toHaveLength(2)
|
||||
expect(result.worktrees.map((worktree) => worktree.hostId)).toContain(
|
||||
`ssh:${SSH_CONNECTION_ID}`
|
||||
)
|
||||
expect(result.hostScope?.hostIds).toEqual(['local', `ssh:${SSH_CONNECTION_ID}`])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Chooses which rows survive a listing's row cap so that no execution host is starved by it.
|
||||
*
|
||||
* Worktree rows are resolved repo by repo, so every SSH repo's rows land contiguously at the end
|
||||
* of the fleet order — 24 remote worktrees sat at indices 496-520 of 521 and a 200-row cap
|
||||
* returned zero of them (#18104). A per-host round robin gives each host a share of the cap.
|
||||
*
|
||||
* Chosen rows keep the caller's original relative order, so the page stays a subsequence of the
|
||||
* unbounded listing and nothing downstream has to re-sort. An uncapped listing is returned as-is.
|
||||
*/
|
||||
export function selectHostBalancedPage<TRow>(
|
||||
rows: readonly TRow[],
|
||||
limit: number,
|
||||
getHostId: (row: TRow) => string | null | undefined
|
||||
): TRow[] {
|
||||
if (rows.length <= limit) {
|
||||
return [...rows]
|
||||
}
|
||||
// Insertion order is first-appearance order per host, so the round robin is deterministic.
|
||||
const indicesByHost = new Map<string, number[]>()
|
||||
rows.forEach((row, index) => {
|
||||
const hostId = getHostId(row) ?? ''
|
||||
const bucket = indicesByHost.get(hostId)
|
||||
if (bucket) {
|
||||
bucket.push(index)
|
||||
} else {
|
||||
indicesByHost.set(hostId, [index])
|
||||
}
|
||||
})
|
||||
const buckets = [...indicesByHost.values()]
|
||||
const cursors = buckets.map(() => 0)
|
||||
const chosen: number[] = []
|
||||
while (chosen.length < limit) {
|
||||
let advanced = false
|
||||
for (let bucket = 0; bucket < buckets.length && chosen.length < limit; bucket += 1) {
|
||||
const cursor = cursors[bucket] ?? 0
|
||||
const index = buckets[bucket]?.[cursor]
|
||||
if (index !== undefined) {
|
||||
chosen.push(index)
|
||||
cursors[bucket] = cursor + 1
|
||||
advanced = true
|
||||
}
|
||||
}
|
||||
if (!advanced) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return chosen.sort((left, right) => left - right).map((index) => rows[index] as TRow)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
|
||||
/**
|
||||
* What a bounded listing did and did not cover, by execution host. An absent scope means the
|
||||
* host is too old to report one — not that it covered everything. See
|
||||
* `docs/reference/ssh-execution-boundary.md`: a listing is only evidence about the hosts it
|
||||
* actually covered, so an empty answer for a host that is missing here proves nothing.
|
||||
*/
|
||||
export type RuntimeListingHostScope = {
|
||||
hostIds: ExecutionHostId[]
|
||||
omittedHostIds: ExecutionHostId[]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import type { StartupCommandDelivery } from './codex-startup-delivery'
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import type { PtyIncarnationId } from './pty-incarnation'
|
||||
import type { RuntimeListingHostScope } from './runtime-listing-host-scope'
|
||||
import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts'
|
||||
import type { TabGroupLayoutNode } from './tab-types'
|
||||
import type { TerminalExitCause } from './terminal-exit-cause'
|
||||
@@ -83,10 +84,8 @@ export type RuntimeTerminalVisualLayout = {
|
||||
root: RuntimeTerminalVisualLayoutNode
|
||||
}
|
||||
|
||||
export type RuntimeTerminalListHostScope = {
|
||||
hostIds: ExecutionHostId[]
|
||||
omittedHostIds: ExecutionHostId[]
|
||||
}
|
||||
/** The shared listing-scope shape, kept under its incumbent name for existing consumers. */
|
||||
export type RuntimeTerminalListHostScope = RuntimeListingHostScope
|
||||
|
||||
export type RuntimeTerminalListResult = {
|
||||
terminals: RuntimeTerminalSummary[]
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorktreeLineage,
|
||||
WorktreeLineageWarning
|
||||
} from './worktree/lineage-types'
|
||||
import type { RuntimeListingHostScope } from './runtime-listing-host-scope'
|
||||
import type { GitWorktreeInfo, Worktree } from './worktree/types'
|
||||
|
||||
export type RuntimeWorktreeAgentRow = {
|
||||
@@ -125,6 +126,8 @@ export type RuntimeWorktreePsResult = {
|
||||
worktrees: RuntimeWorktreePsSummary[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
/** Absent from hosts that predate the field; treat that scope as unverifiable. */
|
||||
hostScope?: RuntimeListingHostScope
|
||||
}
|
||||
|
||||
export type RuntimeWorktreePsSnapshotResult = RuntimeWorktreePsResult & { snapshotId: string }
|
||||
@@ -150,4 +153,6 @@ export type RuntimeWorktreeListResult = {
|
||||
worktrees: RuntimeWorktreeRecord[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
/** Absent from hosts that predate the field; treat that scope as unverifiable. */
|
||||
hostScope?: RuntimeListingHostScope
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user