Fix Resource Manager going empty when a runtime server is selected or stale (#7275)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d1bd91417e
commit
748bced2e6
|
|
@ -178,6 +178,7 @@ import { applyElectronProxySettings } from './network/proxy-settings'
|
|||
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
|
||||
import { CliInstaller } from './cli/cli-installer'
|
||||
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
|
||||
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
|
||||
|
|
@ -1565,6 +1566,7 @@ app.whenReady().then(async () => {
|
|||
|
||||
store = new Store()
|
||||
logStartupMilestone('store-loaded')
|
||||
selfHealRuntimeEnvironmentFocus({ store, userDataPath: app.getPath('userData') })
|
||||
applyAppIcon(store.getSettings().appIcon)
|
||||
if (shouldSuppressDevEducation({ isDev: is.dev })) {
|
||||
suppressDevEducationForStore(store)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
removeEnvironment,
|
||||
updateEnvironmentFromPairingCode
|
||||
} from '../../shared/runtime-environment-store'
|
||||
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
|
||||
import {
|
||||
cleanupEphemeralVmRuntime,
|
||||
resumeEphemeralVmRuntime,
|
||||
|
|
@ -102,6 +103,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
|||
if (result.ok && runtime.runtimeEnvironmentId) {
|
||||
try {
|
||||
removeEnvironment(userDataPath, runtime.runtimeEnvironmentId)
|
||||
clearActiveRuntimeEnvironmentFocusIfMatches(store, runtime.runtimeEnvironmentId)
|
||||
} catch {
|
||||
// Cleanup of provider resources matters more than hiding a stale local
|
||||
// environment row; users can still remove that manually.
|
||||
|
|
|
|||
|
|
@ -71,9 +71,14 @@ function makeStore(repoPath: string) {
|
|||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
let activeRuntimeEnvironmentId: string | null = null
|
||||
return {
|
||||
getRepo: vi.fn((repoId: string) => (repoId === 'repo-1' ? repo : null)),
|
||||
getRepos: vi.fn(() => [repo])
|
||||
getRepos: vi.fn(() => [repo]),
|
||||
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId })),
|
||||
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
|
||||
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +134,8 @@ describe('registerEphemeralVmHandlers', () => {
|
|||
].join('\n')
|
||||
)
|
||||
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
const store = makeStore(repoPath)
|
||||
registerEphemeralVmHandlers(store as never)
|
||||
const result = await handlers.get('ephemeralVm:listRecipes')?.(null, {
|
||||
repoId: 'repo-1'
|
||||
} as never)
|
||||
|
|
@ -163,7 +169,8 @@ describe('registerEphemeralVmHandlers', () => {
|
|||
].join('\n')
|
||||
)
|
||||
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
const store = makeStore(repoPath)
|
||||
registerEphemeralVmHandlers(store as never)
|
||||
const result = await handlers.get('ephemeralVm:listRecipeCatalog')?.(null, undefined as never)
|
||||
|
||||
expect(result).toEqual([
|
||||
|
|
@ -212,7 +219,8 @@ describe('registerEphemeralVmHandlers', () => {
|
|||
].join('\n')
|
||||
)
|
||||
|
||||
registerEphemeralVmHandlers(makeStore(repoPath) as never)
|
||||
const store = makeStore(repoPath)
|
||||
registerEphemeralVmHandlers(store as never)
|
||||
const result = (await handlers.get('ephemeralVm:provision')?.(null, {
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
|
|
@ -253,6 +261,17 @@ describe('registerEphemeralVmHandlers', () => {
|
|||
workspaceId: 'repo-1::/workspace/repo/worktree'
|
||||
})
|
||||
)
|
||||
|
||||
store.updateSettings({ activeRuntimeEnvironmentId: result.environment!.id })
|
||||
const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, {
|
||||
runtimeId: result.runtime?.id
|
||||
} as never)
|
||||
expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' }))
|
||||
expect(listEnvironments(userDataPath)).toEqual([])
|
||||
expect(store.updateSettings).toHaveBeenLastCalledWith(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('provisions an ssh recipe without creating a runtime environment', async () => {
|
||||
|
|
|
|||
|
|
@ -443,7 +443,7 @@ describe('registerCoreHandlers', () => {
|
|||
expect(registerEmulatorVideoStreamHandlersMock).toHaveBeenCalled()
|
||||
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled()
|
||||
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
|
||||
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export function registerCoreHandlers(
|
|||
}
|
||||
registerFilesystemWatcherHandlers()
|
||||
registerRuntimeHandlers(runtime)
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store)
|
||||
registerEphemeralVmHandlers(store)
|
||||
registerAiVaultHandlers({
|
||||
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
|
||||
|
|
|
|||
|
|
@ -80,9 +80,21 @@ function handler<TArgs, TResult>(
|
|||
|
||||
describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
let userDataPath: string
|
||||
let activeRuntimeEnvironmentId: string | null
|
||||
let store: {
|
||||
getSettings: () => { activeRuntimeEnvironmentId: string | null }
|
||||
updateSettings: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-'))
|
||||
activeRuntimeEnvironmentId = null
|
||||
store = {
|
||||
getSettings: () => ({ activeRuntimeEnvironmentId }),
|
||||
updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => {
|
||||
activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
|
||||
})
|
||||
}
|
||||
getPathMock.mockReset()
|
||||
getPathMock.mockReturnValue(userDataPath)
|
||||
handleMock.mockReset()
|
||||
|
|
@ -104,7 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('registers desktop runtime environment management handlers', () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
|
||||
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:list',
|
||||
|
|
@ -123,7 +135,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('clears stale IPC registrations before registering runtime environment handlers', () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
|
||||
expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'runtimeEnvironments:list',
|
||||
|
|
@ -140,7 +152,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('stores, resolves, lists, and removes environments under Electron userData', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
|
|
@ -149,6 +161,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
expect(JSON.stringify(added)).not.toContain('device-token')
|
||||
expect(JSON.stringify(added)).not.toContain('publicKeyB64')
|
||||
activeRuntimeEnvironmentId = added.environment.id
|
||||
|
||||
const list = handler<undefined, { id: string; name: string }[]>('runtimeEnvironments:list')
|
||||
expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
|
||||
|
|
@ -170,13 +183,18 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
expect(removed).toMatchObject({
|
||||
removed: { id: added.environment.id, name: 'desk' }
|
||||
})
|
||||
expect(store.updateSettings).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
expect(activeRuntimeEnvironmentId).toBeNull()
|
||||
expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
|
||||
expect(JSON.stringify(removed)).not.toContain('device-token')
|
||||
expect(await list(null, undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('disconnects a saved runtime without removing it', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
|
|
@ -200,7 +218,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('marks environments owned by ephemeral VM runtimes in the public list', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
|
||||
// The ephemeral-VM provision flow persists `source: 'ephemeral-vm'` directly
|
||||
// on the environment record (ephemeral-vm.ts), so the public list reads it
|
||||
|
|
@ -221,7 +239,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('checks a saved remote runtime and records the runtime id on success', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
|
|
@ -260,7 +278,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('attaches shared-control diagnostics to saved remote runtime status', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 1,
|
||||
|
|
@ -296,7 +314,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('attaches shared-control diagnostics to failed saved remote runtime status', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 0,
|
||||
|
|
@ -334,7 +352,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('returns shared-control diagnostics when saved remote runtime status throws', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 0,
|
||||
|
|
@ -367,7 +385,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('proxies generic one-shot RPC calls to the saved remote runtime', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'rpc-2',
|
||||
ok: true,
|
||||
|
|
@ -401,7 +419,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => {
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
|
|
@ -442,7 +460,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('uses the cached request connection for terminal hot path RPCs', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
|
||||
id: 'rpc-terminal',
|
||||
ok: true,
|
||||
|
|
@ -482,7 +500,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('keeps terminal hot path RPCs on the cached request connection when shared control is supported', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
|
|
@ -545,7 +563,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('routes one-shot RPC calls through shared control when the runtime advertises support', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
|
|
@ -599,7 +617,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('rechecks shared-control support when the saved runtime identity changes', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
let statusCalls = 0
|
||||
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => {
|
||||
if (method === 'status.get') {
|
||||
|
|
@ -654,7 +672,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('does not fall back after a shared-control request fails on a supported runtime', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
|
|
@ -691,7 +709,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('keeps browser and terminal heavy streams on dedicated subscription sockets', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
|
|
@ -761,7 +779,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('routes passive subscriptions through shared control when supported', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
|
|
@ -814,7 +832,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('keeps shared-control subscriptions retained across transient errors until final close', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
const senderSend = vi.fn()
|
||||
const destroyedListenerRemoved = vi.fn()
|
||||
|
|
@ -893,7 +911,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('falls back to legacy passive subscriptions when shared control is unsupported', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
|
|
@ -942,7 +960,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('dedupes concurrent shared-control capability probes per environment', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
let resolveStatus: (value: unknown) => void = () => {}
|
||||
sendRemoteRuntimeRequestMock.mockImplementation((_pairing, method) => {
|
||||
if (method === 'status.get') {
|
||||
|
|
@ -989,7 +1007,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('clears rejected shared-control capability probes so a later call can retry', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock
|
||||
.mockRejectedValueOnce(new Error('probe failed'))
|
||||
.mockResolvedValueOnce({
|
||||
|
|
@ -1031,7 +1049,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('clears shared-control capability cache when a runtime is disconnected', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
|
|
@ -1075,7 +1093,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('clears shared-control capability cache when a runtime is removed and re-added', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
|
|
@ -1119,7 +1137,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('limits background one-shot RPCs without blocking foreground runtime calls', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const pendingBackground: ((value: unknown) => void)[] = []
|
||||
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => {
|
||||
if (method === 'status.get') {
|
||||
|
|
@ -1210,7 +1228,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('starts and stops streaming subscriptions for a saved remote runtime', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
const sendBinary = vi.fn()
|
||||
const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed')
|
||||
|
|
@ -1307,7 +1325,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('closes streaming subscriptions when their saved runtime is removed', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
const sendBinary = vi.fn()
|
||||
subscribeRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
|
|
@ -1371,7 +1389,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('rejects cross-window streaming subscription control', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
const sendBinary = vi.fn()
|
||||
subscribeRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
|
|
@ -1441,7 +1459,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('closes a streaming subscription that resolves after the sender is destroyed', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
const close = vi.fn()
|
||||
let resolveSubscribe: (value: {
|
||||
requestId: string
|
||||
|
|
@ -1519,7 +1537,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
|||
})
|
||||
|
||||
it('removes the destroyed listener when streaming subscription setup rejects', async () => {
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed'))
|
||||
|
||||
const add = handler<
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import {
|
|||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
|
||||
import type { Store } from '../persistence'
|
||||
import { clearActiveRuntimeEnvironmentFocusIfMatches } from '../runtime-environment-focus-self-heal'
|
||||
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
|
|
@ -63,7 +65,7 @@ function listPublicRuntimeEnvironments(): PublicKnownRuntimeEnvironment[] {
|
|||
return listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
|
||||
}
|
||||
|
||||
export function registerRuntimeEnvironmentHandlers(): void {
|
||||
export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
// Why: keep direct re-registration safe even though register-core-handlers
|
||||
// normally guards this path; otherwise the binary send listener can stack.
|
||||
resetSharedControlSupport()
|
||||
|
|
@ -99,6 +101,7 @@ export function registerRuntimeEnvironmentHandlers(): void {
|
|||
closeRemoteRuntimeRequestConnection(args.selector)
|
||||
clearSharedControlSupport(args.selector)
|
||||
}
|
||||
clearActiveRuntimeEnvironmentFocusIfMatches(store, removed.id)
|
||||
closeSubscriptionsForEnvironment(removed.id)
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../shared/types'
|
||||
import type { KnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
import {
|
||||
clearActiveRuntimeEnvironmentFocusIfMatches,
|
||||
selfHealRuntimeEnvironmentFocus
|
||||
} from './runtime-environment-focus-self-heal'
|
||||
|
||||
function environment(
|
||||
id: string,
|
||||
source?: KnownRuntimeEnvironment['source']
|
||||
): KnownRuntimeEnvironment {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
...(source ? { source } : {}),
|
||||
endpoints: [
|
||||
{
|
||||
id: `ws-${id}`,
|
||||
kind: 'websocket',
|
||||
label: 'WebSocket',
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key'
|
||||
}
|
||||
],
|
||||
preferredEndpointId: `ws-${id}`
|
||||
}
|
||||
}
|
||||
|
||||
function makeStore(activeRuntimeEnvironmentId: string | null | undefined) {
|
||||
const settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> = {}
|
||||
if (activeRuntimeEnvironmentId !== undefined) {
|
||||
settings.activeRuntimeEnvironmentId = activeRuntimeEnvironmentId
|
||||
}
|
||||
const updateSettings = vi.fn((updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>) => {
|
||||
settings.activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId
|
||||
return settings
|
||||
})
|
||||
return {
|
||||
store: {
|
||||
getSettings: () => settings,
|
||||
updateSettings
|
||||
},
|
||||
updateSettings
|
||||
}
|
||||
}
|
||||
|
||||
describe('runtime environment focus self-heal', () => {
|
||||
it('keeps a focus id that resolves to a user-managed environment', () => {
|
||||
const { store, updateSettings } = makeStore('env-1')
|
||||
|
||||
selfHealRuntimeEnvironmentFocus({
|
||||
store,
|
||||
userDataPath: '/user-data',
|
||||
listKnownEnvironments: () => [environment('env-1')]
|
||||
})
|
||||
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a dangling focus id and logs one diagnostic line', () => {
|
||||
const { store, updateSettings } = makeStore('missing-env')
|
||||
const log = vi.fn()
|
||||
|
||||
selfHealRuntimeEnvironmentFocus({
|
||||
store,
|
||||
userDataPath: '/user-data',
|
||||
listKnownEnvironments: () => [environment('env-1')],
|
||||
log
|
||||
})
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
expect(log.mock.calls[0][0]).toContain('missing-env')
|
||||
})
|
||||
|
||||
it('clears an ephemeral-VM focus id after restart', () => {
|
||||
const { store, updateSettings } = makeStore('vm-env')
|
||||
const log = vi.fn()
|
||||
|
||||
selfHealRuntimeEnvironmentFocus({
|
||||
store,
|
||||
userDataPath: '/user-data',
|
||||
listKnownEnvironments: () => [environment('vm-env', 'ephemeral-vm')],
|
||||
log
|
||||
})
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
|
||||
expect(log).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves null and absent focus settings untouched', () => {
|
||||
const nullCase = makeStore(null)
|
||||
const absentCase = makeStore(undefined)
|
||||
const listKnownEnvironments = vi.fn(() => [environment('env-1')])
|
||||
|
||||
selfHealRuntimeEnvironmentFocus({
|
||||
store: nullCase.store,
|
||||
userDataPath: '/user-data',
|
||||
listKnownEnvironments
|
||||
})
|
||||
selfHealRuntimeEnvironmentFocus({
|
||||
store: absentCase.store,
|
||||
userDataPath: '/user-data',
|
||||
listKnownEnvironments
|
||||
})
|
||||
|
||||
expect(nullCase.updateSettings).not.toHaveBeenCalled()
|
||||
expect(absentCase.updateSettings).not.toHaveBeenCalled()
|
||||
expect(listKnownEnvironments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes an empty persisted id to null without reading the registry', () => {
|
||||
const { store, updateSettings } = makeStore('')
|
||||
const listKnownEnvironments = vi.fn(() => [environment('env-1')])
|
||||
|
||||
selfHealRuntimeEnvironmentFocus({
|
||||
store,
|
||||
userDataPath: '/user-data',
|
||||
listKnownEnvironments
|
||||
})
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({ activeRuntimeEnvironmentId: null })
|
||||
expect(listKnownEnvironments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails soft when the registry cannot be read', () => {
|
||||
const { store, updateSettings } = makeStore('env-1')
|
||||
|
||||
selfHealRuntimeEnvironmentFocus({
|
||||
store,
|
||||
userDataPath: '/user-data',
|
||||
listKnownEnvironments: () => {
|
||||
throw new Error('invalid registry')
|
||||
}
|
||||
})
|
||||
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the active focus on matching in-process removal with listener notification', () => {
|
||||
const { store, updateSettings } = makeStore('env-1')
|
||||
|
||||
clearActiveRuntimeEnvironmentFocusIfMatches(store, 'env-1')
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { GlobalSettings } from '../shared/types'
|
||||
import { listEnvironments } from '../shared/runtime-environment-store'
|
||||
import {
|
||||
isUserManagedRuntimeEnvironment,
|
||||
type KnownRuntimeEnvironment
|
||||
} from '../shared/runtime-environments'
|
||||
|
||||
type RuntimeEnvironmentFocusStore = {
|
||||
getSettings: () => Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>
|
||||
updateSettings: (
|
||||
updates: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'>,
|
||||
options?: { notifyListeners?: boolean }
|
||||
) => unknown
|
||||
}
|
||||
|
||||
type SelfHealRuntimeEnvironmentFocusArgs = {
|
||||
store: RuntimeEnvironmentFocusStore
|
||||
userDataPath: string
|
||||
listKnownEnvironments?: (userDataPath: string) => KnownRuntimeEnvironment[]
|
||||
log?: (message: string) => void
|
||||
}
|
||||
|
||||
function logClearedFocus(log: ((message: string) => void) | undefined, reason: string): void {
|
||||
const writeLog = log ?? console.info
|
||||
writeLog(`[runtime-environment-focus] cleared active runtime environment: ${reason}`)
|
||||
}
|
||||
|
||||
export function clearActiveRuntimeEnvironmentFocusIfMatches(
|
||||
store: RuntimeEnvironmentFocusStore,
|
||||
environmentId: string
|
||||
): void {
|
||||
if (store.getSettings().activeRuntimeEnvironmentId !== environmentId) {
|
||||
return
|
||||
}
|
||||
store.updateSettings({ activeRuntimeEnvironmentId: null }, { notifyListeners: true })
|
||||
}
|
||||
|
||||
export function selfHealRuntimeEnvironmentFocus({
|
||||
store,
|
||||
userDataPath,
|
||||
listKnownEnvironments = listEnvironments,
|
||||
log
|
||||
}: SelfHealRuntimeEnvironmentFocusArgs): void {
|
||||
const activeRuntimeEnvironmentId = store.getSettings().activeRuntimeEnvironmentId
|
||||
if (activeRuntimeEnvironmentId === undefined || activeRuntimeEnvironmentId === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeRuntimeEnvironmentId.trim() === '') {
|
||||
store.updateSettings({ activeRuntimeEnvironmentId: null })
|
||||
logClearedFocus(log, 'empty persisted id')
|
||||
return
|
||||
}
|
||||
|
||||
let environments: KnownRuntimeEnvironment[]
|
||||
try {
|
||||
environments = listKnownEnvironments(userDataPath)
|
||||
} catch {
|
||||
// Why: an unreadable registry must not clear a possibly-valid focus; keep
|
||||
// it and let a later launch heal once the registry reads again.
|
||||
return
|
||||
}
|
||||
|
||||
const focusedEnvironment = environments.find((entry) => entry.id === activeRuntimeEnvironmentId)
|
||||
if (focusedEnvironment && isUserManagedRuntimeEnvironment(focusedEnvironment)) {
|
||||
return
|
||||
}
|
||||
|
||||
store.updateSettings({ activeRuntimeEnvironmentId: null })
|
||||
logClearedFocus(log, `dangling id ${activeRuntimeEnvironmentId}`)
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types'
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const storeState = {}
|
||||
const useAppStore = Object.assign(
|
||||
(selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
{ getState: () => storeState }
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
|
||||
values
|
||||
? Object.entries(values).reduce(
|
||||
(text, [token, value]) => text.replace(`{{${token}}}`, value),
|
||||
fallback
|
||||
)
|
||||
: fallback
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() }
|
||||
}))
|
||||
|
||||
import { WorktreeRow } from './ResourceUsageStatusSegment'
|
||||
|
||||
function makeSession(overrides: Partial<UnifiedSessionRow>): UnifiedSessionRow {
|
||||
return {
|
||||
sessionId: 'sess-1',
|
||||
paneKey: null,
|
||||
pid: 100,
|
||||
label: 'zsh',
|
||||
bound: true,
|
||||
tabId: 'tab-1',
|
||||
cpu: 1,
|
||||
memory: 100,
|
||||
hasLocalSamples: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(overrides: Partial<UnifiedWorktreeRow>): UnifiedWorktreeRow {
|
||||
return {
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'feature-branch',
|
||||
repoId: 'repo-1',
|
||||
repoName: 'repo',
|
||||
cpu: 1,
|
||||
memory: 100,
|
||||
history: [],
|
||||
hasLocalSamples: true,
|
||||
isRemote: false,
|
||||
sessions: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resource manager row presentation', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderWorktreeRow(worktree: UnifiedWorktreeRow): void {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorktreeRow
|
||||
worktree={worktree}
|
||||
storeRecord={null}
|
||||
activeWorktreeId={null}
|
||||
isCollapsed={false}
|
||||
onToggle={() => {}}
|
||||
onNavigate={() => {}}
|
||||
onDelete={() => {}}
|
||||
onKillSession={() => {}}
|
||||
navigateToTab={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps the remote chip and kill affordances on SSH-backed rows', () => {
|
||||
renderWorktreeRow(
|
||||
makeWorktree({
|
||||
isRemote: true,
|
||||
cpu: null,
|
||||
memory: null,
|
||||
sessions: [
|
||||
makeSession({ sessionId: 'ssh-a', bound: true }),
|
||||
makeSession({ sessionId: 'ssh-b', bound: false, tabId: null, cpu: null, memory: null })
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(container.textContent).toContain('· remote')
|
||||
const killButtons = container.querySelectorAll('button[aria-label^="Kill session"]')
|
||||
expect(killButtons).toHaveLength(2)
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Kill session ssh-a"]')
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Kill session ssh-b"]')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps kill affordances on the orphan bucket rows', () => {
|
||||
renderWorktreeRow(
|
||||
makeWorktree({
|
||||
worktreeId: ORPHAN_WORKTREE_ID,
|
||||
worktreeName: 'Orphaned terminals',
|
||||
sessions: [makeSession({ sessionId: 'orphan-a', bound: false, tabId: null })]
|
||||
})
|
||||
)
|
||||
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Kill session orphan-a"]')
|
||||
).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -12,7 +12,7 @@ describe('ResourceUsageStatusSegment session polling', () => {
|
|||
expect(source).not.toContain('SESSIONS_POLL_MS')
|
||||
expect(source.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
|
||||
|
||||
const openEffectIndex = source.indexOf('if (!open || runtimeEnvironmentActive)')
|
||||
const openEffectIndex = source.indexOf('if (!open)')
|
||||
const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex)
|
||||
|
||||
// Why: pty.listSessions() is a global daemon inventory and can pause input
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
|
|||
import { useDaemonActions, DaemonActionDialog } from '../shared/useDaemonActions'
|
||||
import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types'
|
||||
import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup'
|
||||
import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
|
||||
|
|
@ -352,7 +353,9 @@ function sortProjectGroups(groups: UnifiedProjectGroup[], sort: SortOption): Uni
|
|||
|
||||
// ─── Session row ────────────────────────────────────────────────────
|
||||
|
||||
function SessionRow({
|
||||
// Exported (with WorktreeRow) for row-level regression tests pinning the kill
|
||||
// affordance and remote-chip presentation for SSH/orphan rows.
|
||||
export function SessionRow({
|
||||
session,
|
||||
worktreeId,
|
||||
onNavigate,
|
||||
|
|
@ -433,7 +436,7 @@ function SessionRow({
|
|||
|
||||
// ─── Worktree row ───────────────────────────────────────────────────
|
||||
|
||||
function WorktreeRow({
|
||||
export function WorktreeRow({
|
||||
worktree,
|
||||
storeRecord,
|
||||
activeWorktreeId,
|
||||
|
|
@ -745,10 +748,6 @@ export function ResourceUsageStatusSegment({
|
|||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null)
|
||||
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
|
||||
const activeRuntimeEnvironmentId = useAppStore(
|
||||
(s) => s.settings?.activeRuntimeEnvironmentId ?? null
|
||||
)
|
||||
const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim())
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [sortOption, setSortOption] = useState<SortOption>('memory')
|
||||
|
|
@ -770,19 +769,12 @@ export function ResourceUsageStatusSegment({
|
|||
// merged tree needs them only while open, so closed status-bar badges should
|
||||
// not subscribe to those high-churn maps.
|
||||
const runtimePaneTitlesByTabId = useAppStore((s) =>
|
||||
getResourceUsageRuntimePaneTitlesByTabId(s, open, runtimeEnvironmentActive)
|
||||
getResourceUsageRuntimePaneTitlesByTabId(s, open)
|
||||
)
|
||||
const repos = useAppStore((s) => getResourceUsageRepos(s, open, runtimeEnvironmentActive))
|
||||
const allWorktrees = useAppStore((s) =>
|
||||
getResourceUsageAllWorktrees(s, open, runtimeEnvironmentActive)
|
||||
)
|
||||
const tabsByWorktree = useAppStore((s) =>
|
||||
getResourceUsageTabsByWorktree(s, open, runtimeEnvironmentActive)
|
||||
)
|
||||
// Why: this segment only understands the local Electron PTY/resource daemon.
|
||||
// While a runtime server is active, hiding local samples avoids showing or
|
||||
// killing sessions from the wrong machine.
|
||||
const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot
|
||||
const repos = useAppStore((s) => getResourceUsageRepos(s, open))
|
||||
const allWorktrees = useAppStore((s) => getResourceUsageAllWorktrees(s, open))
|
||||
const tabsByWorktree = useAppStore((s) => getResourceUsageTabsByWorktree(s, open))
|
||||
const resourceSnapshot = snapshot
|
||||
// Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource
|
||||
// Manager also reads restored wake hints, but only for classification.
|
||||
const resourceSessionBindings = useMemo<ResourceSessionBindingInputs>(
|
||||
|
|
@ -822,13 +814,6 @@ export function ResourceUsageStatusSegment({
|
|||
)
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
if (runtimeEnvironmentActive) {
|
||||
if (mountedRef.current) {
|
||||
setSessions([])
|
||||
setSessionsError(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.api.pty.listSessions()
|
||||
if (!mountedRef.current) {
|
||||
|
|
@ -841,7 +826,7 @@ export function ResourceUsageStatusSegment({
|
|||
setSessionsError(true)
|
||||
}
|
||||
}
|
||||
}, [mountedRef, runtimeEnvironmentActive])
|
||||
}, [mountedRef])
|
||||
|
||||
const daemonActions = useDaemonActions({
|
||||
onRestartSettled: () => {
|
||||
|
|
@ -858,7 +843,6 @@ export function ResourceUsageStatusSegment({
|
|||
// closes this popover; the status-bar trigger becomes the handoff point.
|
||||
const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({
|
||||
snapshot: spaceScanSnapshot,
|
||||
runtimeEnvironmentActive,
|
||||
open,
|
||||
activeView,
|
||||
scannedAt: workspaceSpaceScannedAt,
|
||||
|
|
@ -880,7 +864,7 @@ export function ResourceUsageStatusSegment({
|
|||
// daemon PTYs because large preserved-session sets make that visible while
|
||||
// typing.
|
||||
useEffect(() => {
|
||||
if (!open || runtimeEnvironmentActive) {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
void fetchSnapshot()
|
||||
|
|
@ -894,14 +878,7 @@ export function ResourceUsageStatusSegment({
|
|||
return () => {
|
||||
window.clearInterval(memTimer)
|
||||
}
|
||||
}, [open, runtimeEnvironmentActive, fetchSnapshot, refreshSessions])
|
||||
|
||||
useEffect(() => {
|
||||
if (runtimeEnvironmentActive) {
|
||||
setSessions([])
|
||||
setSessionsError(false)
|
||||
}
|
||||
}, [runtimeEnvironmentActive])
|
||||
}, [open, fetchSnapshot, refreshSessions])
|
||||
|
||||
const repoDisplayNameById = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
|
|
@ -927,6 +904,17 @@ export function ResourceUsageStatusSegment({
|
|||
return map
|
||||
}, [repos])
|
||||
|
||||
// Why: runtime-hosted repos never have local daemon samples or killable
|
||||
// local sessions; this map drives their per-row exclusion in the merge.
|
||||
const repoRuntimeScopedById = useMemo(() => {
|
||||
const map = new Map<string, boolean>()
|
||||
for (const repo of repos) {
|
||||
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
map.set(repo.id, parsed?.kind === 'runtime')
|
||||
}
|
||||
return map
|
||||
}, [repos])
|
||||
|
||||
const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
|
||||
|
||||
const oldWorkspaceCount = useMemo(() => {
|
||||
|
|
@ -951,7 +939,7 @@ export function ResourceUsageStatusSegment({
|
|||
// feel laggy because the segment is always mounted in the status bar.
|
||||
const unifiedRepos = useMemo(
|
||||
() =>
|
||||
open && !runtimeEnvironmentActive
|
||||
open
|
||||
? mergeSnapshotAndSessions(resourceSnapshot, sessions, {
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
|
|
@ -959,12 +947,12 @@ export function ResourceUsageStatusSegment({
|
|||
runtimePaneTitlesByTabId,
|
||||
workspaceSessionReady,
|
||||
repoDisplayNameById,
|
||||
repoConnectionIdById
|
||||
repoConnectionIdById,
|
||||
repoRuntimeScopedById
|
||||
})
|
||||
: [],
|
||||
[
|
||||
open,
|
||||
runtimeEnvironmentActive,
|
||||
resourceSnapshot,
|
||||
sessions,
|
||||
tabsByWorktree,
|
||||
|
|
@ -973,30 +961,27 @@ export function ResourceUsageStatusSegment({
|
|||
runtimePaneTitlesByTabId,
|
||||
workspaceSessionReady,
|
||||
repoDisplayNameById,
|
||||
repoConnectionIdById
|
||||
repoConnectionIdById,
|
||||
repoRuntimeScopedById
|
||||
]
|
||||
)
|
||||
|
||||
// Why: orphan detection needs daemon inventory. Keep it open-only so the
|
||||
// closed badge never reintroduces a background global session scan.
|
||||
const orphanCount = useMemo(() => {
|
||||
if (!open || !workspaceSessionReady || runtimeEnvironmentActive) {
|
||||
if (!open || !workspaceSessionReady) {
|
||||
return 0
|
||||
}
|
||||
return countUnboundDaemonSessions(sessions, resourceSessionBindings)
|
||||
}, [open, sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive])
|
||||
}, [open, sessions, resourceSessionBindings, workspaceSessionReady])
|
||||
|
||||
const closedSessionCount = useMemo(() => {
|
||||
if (!workspaceSessionReady || runtimeEnvironmentActive) {
|
||||
if (!workspaceSessionReady) {
|
||||
return 0
|
||||
}
|
||||
return buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds.size
|
||||
}, [resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive])
|
||||
const triggerSessionCount = runtimeEnvironmentActive
|
||||
? 0
|
||||
: open
|
||||
? sessions.length
|
||||
: closedSessionCount
|
||||
}, [resourceSessionBindings, workspaceSessionReady])
|
||||
const triggerSessionCount = open ? sessions.length : closedSessionCount
|
||||
|
||||
const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => {
|
||||
const memory = resourceSnapshot?.totalMemory ?? 0
|
||||
|
|
@ -1013,26 +998,20 @@ export function ResourceUsageStatusSegment({
|
|||
// Why: memorySnapshotError is null both for "last fetch succeeded" and
|
||||
// "never fetched". If session refresh fails before a memory snapshot exists,
|
||||
// treat that as daemon-unreachable too.
|
||||
const daemonUnreachable =
|
||||
!runtimeEnvironmentActive &&
|
||||
sessionsError &&
|
||||
(memorySnapshotError !== null || snapshot === null)
|
||||
const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null)
|
||||
// Why: a partial failure where the sessions IPC fails but the snapshot
|
||||
// IPC still works was silently invisible after the merge — the old
|
||||
// SessionsTabPanel surfaced it as "Terminal sessions unavailable". Show
|
||||
// a slim inline notice so the user understands why the session list is
|
||||
// empty/stale even though the resource numbers look fine.
|
||||
const sessionsOnlyError =
|
||||
!runtimeEnvironmentActive && sessionsError && memorySnapshotError === null
|
||||
const sessionsOnlyError = sessionsError && memorySnapshotError === null
|
||||
const resourceManagerTooltipLines = getResourceManagerTooltipLines({
|
||||
memoryLabel: memBadgeLabel,
|
||||
sessionCount: triggerSessionCount,
|
||||
runtimeEnvironmentActive,
|
||||
spaceScanReady
|
||||
})
|
||||
const resourceManagerAriaLabel = getResourceManagerAriaLabel({
|
||||
sessionCount: triggerSessionCount,
|
||||
runtimeEnvironmentActive,
|
||||
spaceScanReady
|
||||
})
|
||||
|
||||
|
|
@ -1089,12 +1068,9 @@ export function ResourceUsageStatusSegment({
|
|||
}, [])
|
||||
|
||||
const handleOpenWorkspaceCleanup = useCallback((): void => {
|
||||
if (runtimeEnvironmentActive) {
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
queueMicrotask(() => openModal('workspace-cleanup'))
|
||||
}, [openModal, runtimeEnvironmentActive])
|
||||
}, [openModal])
|
||||
|
||||
const handleKillSession = useCallback(
|
||||
(session: UnifiedSessionRow): void => {
|
||||
|
|
@ -1205,7 +1181,7 @@ export function ResourceUsageStatusSegment({
|
|||
: resourceManagerAriaLabel
|
||||
}
|
||||
>
|
||||
{spaceScanReady && !runtimeEnvironmentActive ? (
|
||||
{spaceScanReady ? (
|
||||
<span
|
||||
className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
|
|
@ -1276,15 +1252,10 @@ export function ResourceUsageStatusSegment({
|
|||
<div className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-foreground">
|
||||
<MemoryStick className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{runtimeEnvironmentActive
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.6a822b06a7',
|
||||
'Resource Manager'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc',
|
||||
'Resource Manager - Terminals'
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.6d9793d4bc',
|
||||
'Resource Manager - Terminals'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -1294,7 +1265,7 @@ export function ResourceUsageStatusSegment({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => daemonActions.setPending('restart')}
|
||||
disabled={daemonActions.isBusy || runtimeEnvironmentActive}
|
||||
disabled={daemonActions.isBusy}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
|
||||
'Restart daemon'
|
||||
|
|
@ -1305,15 +1276,10 @@ export function ResourceUsageStatusSegment({
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{runtimeEnvironmentActive
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.14ff448686',
|
||||
'Unavailable for runtime servers'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
|
||||
'Restart daemon'
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb',
|
||||
'Restart daemon'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={200}>
|
||||
|
|
@ -1321,7 +1287,7 @@ export function ResourceUsageStatusSegment({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => daemonActions.setPending('killAll')}
|
||||
disabled={daemonActions.isBusy || runtimeEnvironmentActive}
|
||||
disabled={daemonActions.isBusy}
|
||||
aria-label={translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
|
||||
'Kill all sessions'
|
||||
|
|
@ -1332,15 +1298,10 @@ export function ResourceUsageStatusSegment({
|
|||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{runtimeEnvironmentActive
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.14ff448686',
|
||||
'Unavailable for runtime servers'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
|
||||
'Kill all sessions'
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59',
|
||||
'Kill all sessions'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -1578,66 +1539,55 @@ export function ResourceUsageStatusSegment({
|
|||
|
||||
{!resourceSnapshot && !daemonUnreachable && (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
{runtimeEnvironmentActive
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.56b6888304',
|
||||
'Local resource usage hidden for runtime servers.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
|
||||
'Loading…'
|
||||
)}
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55',
|
||||
'Loading…'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!runtimeEnvironmentActive || orphanCount > 0 ? (
|
||||
<div className="border-t border-border/50 px-3 py-2 shrink-0">
|
||||
{!runtimeEnvironmentActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWorkspaceCleanup}
|
||||
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 truncate px-4 text-center">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
|
||||
'Review inactive workspaces ({{value0}})',
|
||||
{ value0: oldWorkspaceCount }
|
||||
<div className="border-t border-border/50 px-3 py-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenWorkspaceCleanup}
|
||||
className="relative inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<span className="min-w-0 truncate px-4 text-center">
|
||||
{translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.92924a14e3',
|
||||
'Review inactive workspaces ({{value0}})',
|
||||
{ value0: oldWorkspaceCount }
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="absolute right-2.5 size-3.5 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{orphanCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleKillOrphans()}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
{orphanCount === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
|
||||
'Kill {{value0}} orphan terminal',
|
||||
{ value0: orphanCount }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
|
||||
'Kill {{value0}} orphan terminals',
|
||||
{ value0: orphanCount }
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="absolute right-2.5 size-3.5 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
{orphanCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleKillOrphans()}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-md border border-border/70 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
{orphanCount === 1
|
||||
? translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.c7e3b1a0d9f2',
|
||||
'Kill {{value0}} orphan terminal',
|
||||
{ value0: orphanCount }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.status.bar.ResourceUsageStatusSegment.d8f4c2b1e0a3',
|
||||
'Kill {{value0}} orphan terminals',
|
||||
{ value0: orphanCount }
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!runtimeEnvironmentActive ? (
|
||||
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
|
||||
) : null}
|
||||
<WorkspaceSpaceCompactPanel onOpenFullPage={openSpaceResults} />
|
||||
</PopoverContent>
|
||||
{/* Why: Radix Dialog must not be a descendant of PopoverContent — when
|
||||
the popover unmounts (e.g. clicking outside, focus moving to the
|
||||
|
|
@ -1718,7 +1668,7 @@ export function ResourceUsageStatusSegment({
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{!runtimeEnvironmentActive && <DaemonActionDialog api={daemonActions} />}
|
||||
<DaemonActionDialog api={daemonActions} />
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const baseCtx = (overrides: Partial<MergeContext> = {}): MergeContext => ({
|
|||
workspaceSessionReady: true,
|
||||
repoDisplayNameById: new Map(),
|
||||
repoConnectionIdById: new Map(),
|
||||
repoRuntimeScopedById: new Map(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
|
|
@ -251,6 +252,58 @@ describe('mergeSnapshotAndSessions', () => {
|
|||
expect(remote.worktrees[0].isRemote).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes runtime-scoped rows while preserving SSH and unattributed sessions', () => {
|
||||
const runtimeWt: WorktreeMemory = {
|
||||
worktreeId: 'runtime-repo::/runtime/Wt',
|
||||
worktreeName: 'Wt',
|
||||
repoId: 'runtime-repo',
|
||||
repoName: 'RUNTIME',
|
||||
cpu: 5,
|
||||
memory: 500_000_000,
|
||||
history: [],
|
||||
sessions: [{ sessionId: 'runtime-pty', paneKey: null, pid: 2, cpu: 5, memory: 500_000_000 }]
|
||||
}
|
||||
const sessions: DaemonSession[] = [
|
||||
{ id: 'runtime-repo::/runtime/Wt@@future-runtime', cwd: '', title: 'runtime/Wt' },
|
||||
{ id: 'ssh-repo::/remote/Wt@@ssh-session', cwd: '', title: 'ssh/Wt' },
|
||||
{ id: 'opaque-local-orphan', cwd: '', title: 'orphan shell' }
|
||||
]
|
||||
const ctx = baseCtx({
|
||||
repoConnectionIdById: new Map<string, string | null>([
|
||||
['runtime-repo', null],
|
||||
['ssh-repo', 'ssh-target-1']
|
||||
]),
|
||||
repoRuntimeScopedById: new Map([
|
||||
['runtime-repo', true],
|
||||
['ssh-repo', false]
|
||||
])
|
||||
})
|
||||
|
||||
const out = mergeSnapshotAndSessions(makeSnapshot([runtimeWt]), sessions, ctx)
|
||||
|
||||
expect(out.map((repo) => repo.repoId)).toEqual(['ssh-repo', UNATTRIBUTED_REPO_ID])
|
||||
expect(out.find((repo) => repo.repoId === 'runtime-repo')).toBeUndefined()
|
||||
|
||||
const ssh = out.find((repo) => repo.repoId === 'ssh-repo')!
|
||||
expect(ssh.hasRemoteChildren).toBe(true)
|
||||
expect(ssh.worktrees[0]).toMatchObject({
|
||||
isRemote: true,
|
||||
cpu: null,
|
||||
memory: null
|
||||
})
|
||||
expect(ssh.worktrees[0].sessions[0]).toMatchObject({
|
||||
sessionId: 'ssh-repo::/remote/Wt@@ssh-session',
|
||||
bound: false
|
||||
})
|
||||
|
||||
const orphan = out.find((repo) => repo.repoId === UNATTRIBUTED_REPO_ID)!
|
||||
expect(orphan.hasRemoteChildren).toBe(false)
|
||||
expect(orphan.worktrees[0].sessions[0]).toMatchObject({
|
||||
sessionId: 'opaque-local-orphan',
|
||||
bound: false
|
||||
})
|
||||
})
|
||||
|
||||
it('unresolvable session falls into unattributed bucket without flagging remote', () => {
|
||||
// Why: under the connectionId predicate, an unresolved session is
|
||||
// not evidence of remoteness — we just don't know what it belongs
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ export function mergeSnapshotAndSessions(
|
|||
return ctx.repoConnectionIdById.get(repoId) != null
|
||||
}
|
||||
|
||||
function isRuntimeScopedRepo(repoId: string): boolean {
|
||||
return ctx.repoRuntimeScopedById.get(repoId) === true
|
||||
}
|
||||
|
||||
function ensureRepo(
|
||||
repoId: string,
|
||||
repoName: string,
|
||||
|
|
@ -183,6 +187,11 @@ export function mergeSnapshotAndSessions(
|
|||
// ── Step 1: ingest snapshot worktrees as the local-truth foundation.
|
||||
if (snapshot) {
|
||||
for (const wt of snapshot.worktrees as readonly WorktreeMemory[]) {
|
||||
// Why: local snapshot data must never render under a runtime-hosted repo
|
||||
// row; belt-and-braces with the matching session-ingest guard below.
|
||||
if (isRuntimeScopedRepo(wt.repoId)) {
|
||||
continue
|
||||
}
|
||||
const repo = ensureRepo(wt.repoId, wt.repoName)
|
||||
const sessions: UnifiedSessionRow[] = wt.sessions.map((s) => {
|
||||
seenSessionIds.add(s.sessionId)
|
||||
|
|
@ -243,6 +252,12 @@ export function mergeSnapshotAndSessions(
|
|||
? session.title || session.id.slice(0, 12)
|
||||
: deriveWorktreeNameFromWorktreeId(finalWorktreeId)
|
||||
|
||||
// Why: the current daemon inputs are local/SSH only; this guard prevents a
|
||||
// future local daemon row accidentally exposing kill actions for runtime PTYs.
|
||||
if (isRuntimeScopedRepo(finalRepoId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const repoIsRemote = isRepoRemote(finalRepoId)
|
||||
const repo = ensureRepo(finalRepoId, finalRepoName, repoIsRemote)
|
||||
if (repoIsRemote) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ describe('resource manager terminal copy', () => {
|
|||
getResourceManagerTooltipLines({
|
||||
memoryLabel: '512 MB',
|
||||
sessionCount: 2,
|
||||
runtimeEnvironmentActive: false,
|
||||
spaceScanReady: false
|
||||
})
|
||||
).toEqual([
|
||||
|
|
@ -25,17 +24,17 @@ describe('resource manager terminal copy', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('does not advertise local session navigation for runtime servers', () => {
|
||||
it('keeps local session copy active under runtime focus', () => {
|
||||
expect(
|
||||
getResourceManagerTooltipLines({
|
||||
memoryLabel: '-',
|
||||
sessionCount: 0,
|
||||
runtimeEnvironmentActive: true,
|
||||
spaceScanReady: true
|
||||
})
|
||||
).toEqual([
|
||||
'Resource Manager - memory unavailable - 0 terminal sessions',
|
||||
'Local terminal sessions are hidden for runtime servers.'
|
||||
'Space scan ready',
|
||||
'No terminal sessions yet.'
|
||||
])
|
||||
})
|
||||
|
||||
|
|
@ -43,7 +42,6 @@ describe('resource manager terminal copy', () => {
|
|||
expect(
|
||||
getResourceManagerAriaLabel({
|
||||
sessionCount: 1,
|
||||
runtimeEnvironmentActive: false,
|
||||
spaceScanReady: true
|
||||
})
|
||||
).toBe('Resource Manager, 1 terminal session, Space scan ready')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export function formatTerminalSessionCount(count: number): string {
|
|||
export function getResourceManagerTooltipLines(args: {
|
||||
memoryLabel: string
|
||||
sessionCount: number
|
||||
runtimeEnvironmentActive: boolean
|
||||
spaceScanReady: boolean
|
||||
}): string[] {
|
||||
const rawMemoryLabel = args.memoryLabel.trim()
|
||||
|
|
@ -17,13 +16,11 @@ export function getResourceManagerTooltipLines(args: {
|
|||
`Resource Manager - ${memoryLabel} - ${formatTerminalSessionCount(args.sessionCount)}`
|
||||
]
|
||||
|
||||
if (args.spaceScanReady && !args.runtimeEnvironmentActive) {
|
||||
if (args.spaceScanReady) {
|
||||
lines.push('Space scan ready')
|
||||
}
|
||||
|
||||
if (args.runtimeEnvironmentActive) {
|
||||
lines.push('Local terminal sessions are hidden for runtime servers.')
|
||||
} else if (args.sessionCount > 0) {
|
||||
if (args.sessionCount > 0) {
|
||||
lines.push('Terminal sessions are grouped by workspace.')
|
||||
} else {
|
||||
lines.push('No terminal sessions yet.')
|
||||
|
|
@ -34,18 +31,13 @@ export function getResourceManagerTooltipLines(args: {
|
|||
|
||||
export function getResourceManagerAriaLabel(args: {
|
||||
sessionCount: number
|
||||
runtimeEnvironmentActive: boolean
|
||||
spaceScanReady: boolean
|
||||
}): string {
|
||||
const parts = ['Resource Manager', formatTerminalSessionCount(args.sessionCount)]
|
||||
|
||||
if (args.spaceScanReady && !args.runtimeEnvironmentActive) {
|
||||
if (args.spaceScanReady) {
|
||||
parts.push('Space scan ready')
|
||||
}
|
||||
|
||||
if (args.runtimeEnvironmentActive) {
|
||||
parts.push('local sessions hidden for runtime server')
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,6 @@ export type MergeContext = {
|
|||
repoDisplayNameById: Map<string, string>
|
||||
/** Repo connectionId by repo id (null/missing == local). */
|
||||
repoConnectionIdById: Map<string, string | null>
|
||||
/** Repo runtime-host scope by repo id (missing == keep row). */
|
||||
repoRuntimeScopedById: Map<string, boolean>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,22 +71,20 @@ describe('resource usage open slices', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('gates repo and worktree slices while closed or runtime-backed', () => {
|
||||
it('gates repo and worktree slices only while closed', () => {
|
||||
const repos = [{ id: 'repo-1', path: '/repo', kind: 'git' }] as AppState['repos']
|
||||
const row = worktree()
|
||||
const worktreesByRepo = {
|
||||
'repo-1': [row]
|
||||
}
|
||||
|
||||
expect(getResourceUsageRepos({ repos }, false, false)).toBe(
|
||||
getResourceUsageRepos({ repos: [] }, false, false)
|
||||
expect(getResourceUsageRepos({ repos }, false)).toBe(
|
||||
getResourceUsageRepos({ repos: [] }, false)
|
||||
)
|
||||
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, false, false)).toBe(
|
||||
getResourceUsageAllWorktrees({ worktreesByRepo: {} }, false, false)
|
||||
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, false)).toBe(
|
||||
getResourceUsageAllWorktrees({ worktreesByRepo: {} }, false)
|
||||
)
|
||||
expect(getResourceUsageRepos({ repos }, true, true)).toEqual([])
|
||||
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, true)).toEqual([])
|
||||
expect(getResourceUsageRepos({ repos }, true, false)).toBe(repos)
|
||||
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true, false)).toEqual([row])
|
||||
expect(getResourceUsageRepos({ repos }, true)).toBe(repos)
|
||||
expect(getResourceUsageAllWorktrees({ worktreesByRepo }, true)).toEqual([row])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,44 +6,36 @@ const EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID: AppState['runtimePaneTitlesByTabId']
|
|||
const EMPTY_REPOS: AppState['repos'] = []
|
||||
const EMPTY_WORKTREES: ReturnType<typeof getAllWorktreesFromState> = []
|
||||
|
||||
function shouldReadPopoverSlices(open: boolean, runtimeEnvironmentActive: boolean): boolean {
|
||||
return open && !runtimeEnvironmentActive
|
||||
function shouldReadPopoverSlices(open: boolean): boolean {
|
||||
return open
|
||||
}
|
||||
|
||||
export function getResourceUsageTabsByWorktree(
|
||||
state: Pick<AppState, 'tabsByWorktree'>,
|
||||
open: boolean,
|
||||
runtimeEnvironmentActive = false
|
||||
open: boolean
|
||||
): AppState['tabsByWorktree'] {
|
||||
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
|
||||
? state.tabsByWorktree
|
||||
: EMPTY_TABS_BY_WORKTREE
|
||||
return shouldReadPopoverSlices(open) ? state.tabsByWorktree : EMPTY_TABS_BY_WORKTREE
|
||||
}
|
||||
|
||||
export function getResourceUsageRuntimePaneTitlesByTabId(
|
||||
state: Pick<AppState, 'runtimePaneTitlesByTabId'>,
|
||||
open: boolean,
|
||||
runtimeEnvironmentActive = false
|
||||
open: boolean
|
||||
): AppState['runtimePaneTitlesByTabId'] {
|
||||
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
|
||||
return shouldReadPopoverSlices(open)
|
||||
? state.runtimePaneTitlesByTabId
|
||||
: EMPTY_RUNTIME_PANE_TITLES_BY_TAB_ID
|
||||
}
|
||||
|
||||
export function getResourceUsageRepos(
|
||||
state: Pick<AppState, 'repos'>,
|
||||
open: boolean,
|
||||
runtimeEnvironmentActive: boolean
|
||||
open: boolean
|
||||
): AppState['repos'] {
|
||||
return shouldReadPopoverSlices(open, runtimeEnvironmentActive) ? state.repos : EMPTY_REPOS
|
||||
return shouldReadPopoverSlices(open) ? state.repos : EMPTY_REPOS
|
||||
}
|
||||
|
||||
export function getResourceUsageAllWorktrees(
|
||||
state: Pick<AppState, 'worktreesByRepo'>,
|
||||
open: boolean,
|
||||
runtimeEnvironmentActive: boolean
|
||||
open: boolean
|
||||
): ReturnType<typeof getAllWorktreesFromState> {
|
||||
return shouldReadPopoverSlices(open, runtimeEnvironmentActive)
|
||||
? getAllWorktreesFromState(state)
|
||||
: EMPTY_WORKTREES
|
||||
return shouldReadPopoverSlices(open) ? getAllWorktreesFromState(state) : EMPTY_WORKTREES
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
expect(
|
||||
resolveResourceUsageSpaceScanReady({
|
||||
snapshot: { ...baseSnapshot, previousScanning: true },
|
||||
runtimeEnvironmentActive: false,
|
||||
open: false,
|
||||
activeView: 'terminal',
|
||||
scannedAt: 100,
|
||||
|
|
@ -32,7 +31,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
expect(
|
||||
resolveResourceUsageSpaceScanReady({
|
||||
snapshot: { ...baseSnapshot, previousScanning: true },
|
||||
runtimeEnvironmentActive: false,
|
||||
open: true,
|
||||
activeView: 'terminal',
|
||||
scannedAt: 100,
|
||||
|
|
@ -47,7 +45,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
expect(
|
||||
resolveResourceUsageSpaceScanReady({
|
||||
snapshot: { ...baseSnapshot, previousScanning: true },
|
||||
runtimeEnvironmentActive: false,
|
||||
open: false,
|
||||
activeView: 'space',
|
||||
scannedAt: 100,
|
||||
|
|
@ -66,7 +63,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
expect(
|
||||
resolveResourceUsageSpaceScanReady({
|
||||
snapshot: readySnapshot,
|
||||
runtimeEnvironmentActive: false,
|
||||
open: true,
|
||||
activeView: 'terminal',
|
||||
scannedAt: 100,
|
||||
|
|
@ -81,7 +77,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
expect(
|
||||
resolveResourceUsageSpaceScanReady({
|
||||
snapshot: readySnapshot,
|
||||
runtimeEnvironmentActive: false,
|
||||
open: false,
|
||||
activeView: 'space',
|
||||
scannedAt: 100,
|
||||
|
|
@ -97,7 +92,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
previousScanning: true,
|
||||
lastSeenScannedAt: 100
|
||||
},
|
||||
runtimeEnvironmentActive: false,
|
||||
open: false,
|
||||
activeView: 'terminal',
|
||||
scannedAt: 100,
|
||||
|
|
@ -108,7 +102,7 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
expect(result.lastSeenScannedAt).toBe(100)
|
||||
})
|
||||
|
||||
it('hides local Space scan handoffs while a remote runtime is active', () => {
|
||||
it('keeps local Space scan handoffs independent of remote runtime focus', () => {
|
||||
expect(
|
||||
resolveResourceUsageSpaceScanReady({
|
||||
snapshot: {
|
||||
|
|
@ -116,15 +110,14 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
previousScanning: true,
|
||||
lastSeenScannedAt: 100
|
||||
},
|
||||
runtimeEnvironmentActive: true,
|
||||
open: false,
|
||||
activeView: 'terminal',
|
||||
scannedAt: 200,
|
||||
scanning: true
|
||||
})
|
||||
).toEqual({
|
||||
ready: false,
|
||||
previousScanning: false,
|
||||
ready: true,
|
||||
previousScanning: true,
|
||||
lastSeenScannedAt: 100
|
||||
})
|
||||
})
|
||||
|
|
@ -133,7 +126,6 @@ describe('resolveResourceUsageSpaceScanReady', () => {
|
|||
expect(
|
||||
resolveResourceUsageSpaceScanReady({
|
||||
snapshot: baseSnapshot,
|
||||
runtimeEnvironmentActive: false,
|
||||
open: false,
|
||||
activeView: 'terminal',
|
||||
scannedAt: null,
|
||||
|
|
|
|||
|
|
@ -6,27 +6,17 @@ export type ResourceUsageSpaceScanSnapshot = {
|
|||
|
||||
export function resolveResourceUsageSpaceScanReady({
|
||||
snapshot,
|
||||
runtimeEnvironmentActive,
|
||||
open,
|
||||
activeView,
|
||||
scannedAt,
|
||||
scanning
|
||||
}: {
|
||||
snapshot: ResourceUsageSpaceScanSnapshot
|
||||
runtimeEnvironmentActive: boolean
|
||||
open: boolean
|
||||
activeView: string
|
||||
scannedAt: number | null
|
||||
scanning: boolean
|
||||
}): ResourceUsageSpaceScanSnapshot {
|
||||
if (runtimeEnvironmentActive) {
|
||||
return {
|
||||
ready: false,
|
||||
previousScanning: false,
|
||||
lastSeenScannedAt: snapshot.lastSeenScannedAt
|
||||
}
|
||||
}
|
||||
|
||||
const scanCompleted =
|
||||
snapshot.previousScanning &&
|
||||
!scanning &&
|
||||
|
|
|
|||
|
|
@ -2845,10 +2845,7 @@
|
|||
"41ae4fa725": "Killing…",
|
||||
"138b99bd80": "this session",
|
||||
"888dad8c55": "Loading…",
|
||||
"56b6888304": "Local resource usage hidden for runtime servers.",
|
||||
"14ff448686": "Unavailable for runtime servers",
|
||||
"6d9793d4bc": "Resource Manager - Terminals",
|
||||
"6a822b06a7": "Resource Manager",
|
||||
"ca95d077db": "Daemon unreachable",
|
||||
"a82253b458": "Delete workspace.",
|
||||
"946724a70a": "The main workspace cannot be deleted.",
|
||||
|
|
|
|||
|
|
@ -2845,10 +2845,7 @@
|
|||
"41ae4fa725": "Asesinato…",
|
||||
"138b99bd80": "esta sesión",
|
||||
"888dad8c55": "Cargando…",
|
||||
"56b6888304": "Uso de recursos locales oculto para servidores de ejecución.",
|
||||
"14ff448686": "No disponible para servidores de ejecución",
|
||||
"6d9793d4bc": "Administrador de recursos - Terminales",
|
||||
"6a822b06a7": "Administrador de recursos",
|
||||
"ca95d077db": "demonio inalcanzable",
|
||||
"a82253b458": "Eliminar espacio de trabajo.",
|
||||
"946724a70a": "El espacio de trabajo principal no se puede eliminar.",
|
||||
|
|
|
|||
|
|
@ -2845,10 +2845,7 @@
|
|||
"41ae4fa725": "終了中…",
|
||||
"138b99bd80": "このセッション",
|
||||
"888dad8c55": "読み込み中…",
|
||||
"56b6888304": "ランタイム サーバーではローカル リソースの使用量が非表示になります。",
|
||||
"14ff448686": "ランタイムサーバーでは使用できません",
|
||||
"6d9793d4bc": "リソースマネージャー - Terminals",
|
||||
"6a822b06a7": "リソースマネージャー",
|
||||
"ca95d077db": "デーモンに到達できません",
|
||||
"a82253b458": "ワークスペースを削除します。",
|
||||
"946724a70a": "メインワークスペースは削除できません。",
|
||||
|
|
|
|||
|
|
@ -2845,10 +2845,7 @@
|
|||
"41ae4fa725": "종료 중…",
|
||||
"138b99bd80": "이번 세션",
|
||||
"888dad8c55": "로드 중…",
|
||||
"56b6888304": "런타임 서버에 대한 로컬 리소스 사용량이 숨겨졌습니다.",
|
||||
"14ff448686": "런타임 서버에서는 사용할 수 없습니다.",
|
||||
"6d9793d4bc": "리소스 관리자 - Terminals",
|
||||
"6a822b06a7": "자원 관리자",
|
||||
"ca95d077db": "데몬에 연결할 수 없음",
|
||||
"a82253b458": "워크스페이스를 삭제합니다.",
|
||||
"946724a70a": "기본 워크스페이스는 삭제할 수 없습니다.",
|
||||
|
|
|
|||
|
|
@ -2845,10 +2845,7 @@
|
|||
"41ae4fa725": "正在结束…",
|
||||
"138b99bd80": "本次会话",
|
||||
"888dad8c55": "加载中…",
|
||||
"56b6888304": "运行时服务器隐藏本地资源使用情况。",
|
||||
"14ff448686": "不可用于运行时服务器",
|
||||
"6d9793d4bc": "资源管理器 - 终端",
|
||||
"6a822b06a7": "资源管理器",
|
||||
"ca95d077db": "守护进程无法访问",
|
||||
"a82253b458": "删除工作区。",
|
||||
"946724a70a": "主工作区无法删除。",
|
||||
|
|
|
|||
Loading…
Reference in New Issue