Improve SSH relay grace period settings (#2549)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ec6a8181da
commit
dac61b84db
|
|
@ -1,9 +1,43 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
RemoteWorkspaceSession,
|
||||
RemoteWorkspaceSnapshot
|
||||
} from '../../shared/remote-workspace-types'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/types'
|
||||
|
||||
const {
|
||||
getActiveMultiplexerMock,
|
||||
getSshConnectionStoreMock,
|
||||
registerRemoteWorkspaceNotificationHandlerMock
|
||||
} = vi.hoisted(() => ({
|
||||
getActiveMultiplexerMock: vi.fn(),
|
||||
getSshConnectionStoreMock: vi.fn(),
|
||||
registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveMultiplexer: getActiveMultiplexerMock,
|
||||
getSshConnectionStore: getSshConnectionStoreMock
|
||||
}))
|
||||
|
||||
vi.mock('./remote-workspace-events', () => ({
|
||||
registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock
|
||||
}))
|
||||
|
||||
import {
|
||||
registerRemoteWorkspaceHandlers,
|
||||
remoteWorkspaceSessionMatchesSnapshot
|
||||
} from './remote-workspace'
|
||||
|
||||
function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot {
|
||||
return {
|
||||
|
|
@ -15,6 +49,30 @@ function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
const baseSession = {
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
} as WorkspaceSessionState
|
||||
|
||||
const targets: SshTarget[] = [
|
||||
{
|
||||
id: 'target-1',
|
||||
label: 'Target 1',
|
||||
host: 'one.example.com',
|
||||
port: 22,
|
||||
username: 'alice'
|
||||
},
|
||||
{
|
||||
id: 'target-2',
|
||||
label: 'Target 2',
|
||||
host: 'two.example.com',
|
||||
port: 22,
|
||||
username: 'alice'
|
||||
}
|
||||
]
|
||||
|
||||
describe('remoteWorkspaceSessionMatchesSnapshot', () => {
|
||||
it('matches normalized equivalent sessions', () => {
|
||||
expect(
|
||||
|
|
@ -85,3 +143,103 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('remoteWorkspace:setForConnectedTargets', () => {
|
||||
const handlers = new Map<string, (event: unknown, args: unknown) => unknown>()
|
||||
const requestByTargetId = new Map<string, ReturnType<typeof vi.fn>>()
|
||||
const muxByTargetId = new Map<string, { request: ReturnType<typeof vi.fn> }>()
|
||||
const store = {
|
||||
getRepo: vi.fn()
|
||||
} as unknown as Store
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
requestByTargetId.clear()
|
||||
muxByTargetId.clear()
|
||||
vi.mocked(ipcMain.handle).mockReset()
|
||||
vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => {
|
||||
handlers.set(channel, handler as (event: unknown, args: unknown) => unknown)
|
||||
})
|
||||
vi.mocked(ipcMain.removeHandler).mockReset()
|
||||
getSshConnectionStoreMock.mockReset()
|
||||
getSshConnectionStoreMock.mockReturnValue({
|
||||
listTargets: () => targets
|
||||
})
|
||||
getActiveMultiplexerMock.mockReset()
|
||||
getActiveMultiplexerMock.mockImplementation((targetId: string) => {
|
||||
let mux = muxByTargetId.get(targetId)
|
||||
if (!mux) {
|
||||
const request = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'workspace.get') {
|
||||
return Promise.resolve(
|
||||
snapshot({
|
||||
activeWorktreePath: '/previous',
|
||||
activeTabId: null,
|
||||
tabsByWorktreePath: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
)
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
snapshot: snapshot({
|
||||
activeWorktreePath: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktreePath: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
})
|
||||
})
|
||||
mux = { request }
|
||||
muxByTargetId.set(targetId, mux)
|
||||
requestByTargetId.set(targetId, request)
|
||||
}
|
||||
return mux
|
||||
})
|
||||
registerRemoteWorkspaceNotificationHandlerMock.mockClear()
|
||||
|
||||
registerRemoteWorkspaceHandlers(store, () => null)
|
||||
})
|
||||
|
||||
async function callSetForConnectedTargets(args: {
|
||||
session: WorkspaceSessionState
|
||||
hydratedTargetIds?: unknown
|
||||
}): Promise<unknown> {
|
||||
const handler = handlers.get('remoteWorkspace:setForConnectedTargets')
|
||||
if (!handler) {
|
||||
throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered')
|
||||
}
|
||||
return handler(null, args)
|
||||
}
|
||||
|
||||
it('does not write without an explicit non-empty hydrated target set', async () => {
|
||||
await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([])
|
||||
await expect(
|
||||
callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: [] })
|
||||
).resolves.toEqual([])
|
||||
await expect(
|
||||
callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] })
|
||||
).resolves.toEqual([])
|
||||
|
||||
expect(getSshConnectionStoreMock).not.toHaveBeenCalled()
|
||||
expect(getActiveMultiplexerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes only to explicitly hydrated connected targets', async () => {
|
||||
const result = await callSetForConnectedTargets({
|
||||
session: baseSession,
|
||||
hydratedTargetIds: ['target-1', 'missing-target']
|
||||
})
|
||||
|
||||
expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }])
|
||||
expect(getActiveMultiplexerMock).toHaveBeenCalledWith('target-1')
|
||||
expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2')
|
||||
expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith(
|
||||
'workspace.patch',
|
||||
expect.objectContaining({
|
||||
patch: expect.objectContaining({ kind: 'replace-session' })
|
||||
})
|
||||
)
|
||||
expect(requestByTargetId.get('target-2')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -152,6 +152,17 @@ function normalizeConnectedClients(
|
|||
.filter((entry): entry is RemoteWorkspaceConnectedClient => entry !== null)
|
||||
}
|
||||
|
||||
function getExplicitHydratedTargetIds(value: unknown): Set<string> | null {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
value.some((targetId) => typeof targetId !== 'string' || targetId.length === 0)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return new Set(value)
|
||||
}
|
||||
|
||||
function targetForWorktree(store: Store, worktreeId: string): string | null {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
return store.getRepo(repoId)?.connectionId ?? null
|
||||
|
|
@ -191,9 +202,6 @@ function importSessionForTarget(
|
|||
}
|
||||
|
||||
async function getRemoteSnapshot(target: SshTarget): Promise<RemoteWorkspaceSnapshot | null> {
|
||||
if (!target.remoteWorkspaceSyncEnabled) {
|
||||
return null
|
||||
}
|
||||
const mux = getActiveMultiplexer(target.id)
|
||||
if (!mux) {
|
||||
return null
|
||||
|
|
@ -221,7 +229,7 @@ export function handleRemoteWorkspaceNotification(
|
|||
return
|
||||
}
|
||||
const target = getSshConnectionStore()?.getTarget(targetId)
|
||||
if (!target?.remoteWorkspaceSyncEnabled) {
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const namespace = getRemoteWorkspaceNamespace(target)
|
||||
|
|
@ -263,18 +271,18 @@ export function registerRemoteWorkspaceHandlers(
|
|||
|
||||
ipcMain.handle(
|
||||
'remoteWorkspace:setForConnectedTargets',
|
||||
async (_event, args: { session: WorkspaceSessionState; hydratedTargetIds?: string[] }) => {
|
||||
const hydratedTargetIds = Array.isArray(args.hydratedTargetIds)
|
||||
? new Set(args.hydratedTargetIds)
|
||||
: null
|
||||
async (_event, args: { session: WorkspaceSessionState; hydratedTargetIds?: unknown }) => {
|
||||
const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds)
|
||||
if (!hydratedTargetIds) {
|
||||
// Why: an omitted hydration set used to broadcast one session to every
|
||||
// SSH target, overwriting unrelated remote workspace snapshots.
|
||||
return []
|
||||
}
|
||||
const targets =
|
||||
getSshConnectionStore()
|
||||
?.listTargets()
|
||||
.filter(
|
||||
(target) =>
|
||||
target.remoteWorkspaceSyncEnabled &&
|
||||
getActiveMultiplexer(target.id) &&
|
||||
(!hydratedTargetIds || hydratedTargetIds.has(target.id))
|
||||
(target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id)
|
||||
) ?? []
|
||||
|
||||
const results: { targetId: string; result: RemoteWorkspacePatchResult }[] = []
|
||||
|
|
@ -331,7 +339,7 @@ export function registerRemoteWorkspaceHandlers(
|
|||
async () =>
|
||||
getSshConnectionStore()
|
||||
?.listTargets()
|
||||
.filter((target) => target.remoteWorkspaceSyncEnabled && getActiveMultiplexer(target.id))
|
||||
.filter((target) => getActiveMultiplexer(target.id))
|
||||
.map((target) => target.id) ?? []
|
||||
)
|
||||
|
||||
|
|
@ -344,7 +352,6 @@ export function registerRemoteWorkspaceHandlers(
|
|||
?.listTargets()
|
||||
.filter(
|
||||
(target) =>
|
||||
target.remoteWorkspaceSyncEnabled &&
|
||||
getActiveMultiplexer(target.id) &&
|
||||
(!requestedTargetIds || requestedTargetIds.has(target.id))
|
||||
) ?? []
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
|||
import { SshRelaySession } from '../ssh/ssh-relay-session'
|
||||
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
|
||||
import {
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
type DetectedPort,
|
||||
type SavedPortForward,
|
||||
type SshTarget,
|
||||
|
|
@ -39,15 +38,7 @@ let portForwardManager: SshPortForwardManager | null = null
|
|||
const activeSessions = new Map<string, SshRelaySession>()
|
||||
|
||||
function relayGracePeriodForTarget(target: SshTarget | null | undefined): number | undefined {
|
||||
if (!target?.remoteWorkspaceSyncEnabled) {
|
||||
return target?.relayGracePeriodSeconds
|
||||
}
|
||||
// Why: cross-device sync should survive transient app closes, but an
|
||||
// unset value must not mean "keep remote PTYs forever" after disconnect.
|
||||
return (
|
||||
target.remoteWorkspaceSyncGracePeriodSeconds ??
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS
|
||||
)
|
||||
return target?.relayGracePeriodSeconds
|
||||
}
|
||||
|
||||
// Why: multiple renderer tabs for the same SSH target can fire ssh:connect
|
||||
|
|
|
|||
|
|
@ -301,6 +301,69 @@ describe('Store', () => {
|
|||
expect(repos[0].gitUsername).toBe('testuser')
|
||||
})
|
||||
|
||||
it('normalizes legacy remote workspace sync fields on SSH targets', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {},
|
||||
sshTargets: [
|
||||
{
|
||||
id: 'ssh-disabled-legacy-grace',
|
||||
label: 'Disabled legacy grace',
|
||||
host: 'disabled.example.com',
|
||||
port: 22,
|
||||
username: 'dev',
|
||||
remoteWorkspaceSyncEnabled: false,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: 0
|
||||
},
|
||||
{
|
||||
id: 'ssh-enabled-legacy-grace',
|
||||
label: 'Enabled legacy grace',
|
||||
host: 'enabled.example.com',
|
||||
port: 22,
|
||||
username: 'dev',
|
||||
remoteWorkspaceSyncEnabled: true,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: 0
|
||||
},
|
||||
{
|
||||
id: 'ssh-new-grace-period-wins',
|
||||
label: 'New grace period',
|
||||
host: 'new.example.com',
|
||||
port: 22,
|
||||
username: 'dev',
|
||||
relayGracePeriodSeconds: 120,
|
||||
remoteWorkspaceSyncEnabled: true,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: 0
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
const targets = store.getSshTargets()
|
||||
|
||||
expect(targets[0]).not.toHaveProperty('relayGracePeriodSeconds')
|
||||
expect(targets[1].relayGracePeriodSeconds).toBe(0)
|
||||
expect(targets[2].relayGracePeriodSeconds).toBe(120)
|
||||
for (const target of targets) {
|
||||
expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled')
|
||||
expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds')
|
||||
}
|
||||
|
||||
store.flush()
|
||||
const persisted = readDataFile() as { sshTargets?: Record<string, unknown>[] }
|
||||
expect(persisted.sshTargets?.[0]).not.toHaveProperty('relayGracePeriodSeconds')
|
||||
expect(persisted.sshTargets?.[1]?.relayGracePeriodSeconds).toBe(0)
|
||||
expect(persisted.sshTargets?.[2]?.relayGracePeriodSeconds).toBe(120)
|
||||
for (const target of persisted.sshTargets ?? []) {
|
||||
expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled')
|
||||
expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds')
|
||||
}
|
||||
})
|
||||
|
||||
it('drops malformed migration-unsupported PTY entries on load', async () => {
|
||||
const repo = makeRepo()
|
||||
writeDataFile({
|
||||
|
|
|
|||
|
|
@ -240,10 +240,36 @@ function normalizeAutomationSessionReuse(automation: Automation): Automation {
|
|||
}
|
||||
}
|
||||
|
||||
type LegacySshTarget = SshTarget & {
|
||||
remoteWorkspaceSyncEnabled?: unknown
|
||||
remoteWorkspaceSyncGracePeriodSeconds?: unknown
|
||||
}
|
||||
|
||||
// Why: old persisted targets predate configHost. Default to label-based lookup
|
||||
// so imported SSH aliases keep resolving through ssh -G after upgrade.
|
||||
function normalizeSshTarget(t: SshTarget): SshTarget {
|
||||
return { ...t, configHost: t.configHost ?? t.label ?? t.host }
|
||||
const target = { ...(t as LegacySshTarget) }
|
||||
const legacySyncEnabled = target.remoteWorkspaceSyncEnabled
|
||||
const currentGracePeriodSeconds = target.relayGracePeriodSeconds
|
||||
const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds
|
||||
// Why: remote workspace sync now follows the SSH relay lifecycle, so the
|
||||
// retired per-target sync opt-out and grace-period fields stop at disk load.
|
||||
delete target.remoteWorkspaceSyncEnabled
|
||||
delete target.remoteWorkspaceSyncGracePeriodSeconds
|
||||
delete target.relayGracePeriodSeconds
|
||||
const relayGracePeriodSeconds =
|
||||
currentGracePeriodSeconds ??
|
||||
(legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number'
|
||||
? legacyGracePeriodSeconds
|
||||
: undefined)
|
||||
const normalized: SshTarget = {
|
||||
...target,
|
||||
configHost: target.configHost ?? target.label ?? target.host
|
||||
}
|
||||
if (relayGracePeriodSeconds !== undefined) {
|
||||
normalized.relayGracePeriodSeconds = relayGracePeriodSeconds
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Why: shared by load-time merge and the IPC update handler so the same
|
||||
|
|
|
|||
|
|
@ -52,7 +52,10 @@ vi.mock('./ssh-connection-utils', () => ({
|
|||
import { deployAndLaunchRelay } from './ssh-relay-deploy'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types'
|
||||
import {
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
} from '../../shared/ssh-types'
|
||||
|
||||
function makeMockConnection(): SshConnection {
|
||||
return {
|
||||
|
|
@ -151,6 +154,25 @@ describe('deployAndLaunchRelay', () => {
|
|||
expect(launchCommand).toContain('--grace-time 0')
|
||||
})
|
||||
|
||||
it('clamps configured SSH disconnect grace to the seven-day maximum', async () => {
|
||||
const conn = makeMockConnection()
|
||||
const mockExecCommand = vi.mocked(execCommand)
|
||||
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
|
||||
mockExecCommand.mockResolvedValueOnce('/home/user')
|
||||
mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK')
|
||||
mockExecCommand.mockResolvedValueOnce('DEAD')
|
||||
mockExecCommand.mockResolvedValueOnce('READY')
|
||||
|
||||
await deployAndLaunchRelay(conn, undefined, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + 1, 'target-a')
|
||||
|
||||
const launchCommand = vi
|
||||
.mocked(conn.exec)
|
||||
.mock.calls.map(([cmd]) => cmd as string)
|
||||
.find((cmd) => cmd.includes('--detached'))
|
||||
|
||||
expect(launchCommand).toContain(`--grace-time ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}`)
|
||||
})
|
||||
|
||||
it('uses a content-hashed versioned remote install directory', async () => {
|
||||
const conn = makeMockConnection()
|
||||
const mockExecCommand = vi.mocked(execCommand)
|
||||
|
|
|
|||
|
|
@ -134,6 +134,18 @@ describe('PtyHandler', () => {
|
|||
expect(onExpire).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not expire an unlimited grace timer', () => {
|
||||
const onExpire = vi.fn()
|
||||
handler.startGraceTimer(onExpire, 100)
|
||||
|
||||
expect(handler.graceTimerActive).toBe(true)
|
||||
handler.startGraceTimer(onExpire, 0)
|
||||
|
||||
expect(handler.graceTimerActive).toBe(false)
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(onExpire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('spawns a PTY and returns an id', async () => {
|
||||
const result = await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 })
|
||||
expect(result).toEqual({ id: 'pty-1' })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
|
|||
import { toast } from 'sonner'
|
||||
import { Plus, Upload } from 'lucide-react'
|
||||
import {
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
|
|
@ -90,23 +89,18 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
return
|
||||
}
|
||||
|
||||
const graceSeconds = parseInt(form.relayGracePeriodSeconds, 10)
|
||||
const graceSeconds = form.relayKeepAliveUntilReset
|
||||
? 0
|
||||
: parseInt(form.relayGracePeriodSeconds, 10)
|
||||
if (
|
||||
isNaN(graceSeconds) ||
|
||||
(graceSeconds !== 0 && graceSeconds < MIN_SSH_RELAY_GRACE_PERIOD_SECONDS) ||
|
||||
graceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
!form.relayKeepAliveUntilReset &&
|
||||
(isNaN(graceSeconds) ||
|
||||
graceSeconds < MIN_SSH_RELAY_GRACE_PERIOD_SECONDS ||
|
||||
graceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS)
|
||||
) {
|
||||
toast.error('Relay grace period must be 0 or between 60 and 10800 seconds')
|
||||
return
|
||||
}
|
||||
const remoteGraceSeconds = parseInt(form.remoteWorkspaceSyncGracePeriodSeconds, 10)
|
||||
if (
|
||||
form.remoteWorkspaceSyncEnabled &&
|
||||
(isNaN(remoteGraceSeconds) ||
|
||||
remoteGraceSeconds < 0 ||
|
||||
remoteGraceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS)
|
||||
) {
|
||||
toast.error('Synced relay grace period must be between 0 and 10800 seconds')
|
||||
toast.error(
|
||||
`Relay grace period must be between 60 and ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} seconds, or choose keep alive until reset`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -117,10 +111,6 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
port,
|
||||
username: form.username.trim(),
|
||||
relayGracePeriodSeconds: graceSeconds,
|
||||
remoteWorkspaceSyncEnabled: form.remoteWorkspaceSyncEnabled,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: form.remoteWorkspaceSyncEnabled
|
||||
? remoteGraceSeconds
|
||||
: DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
...(form.identityFile.trim() ? { identityFile: form.identityFile.trim() } : {}),
|
||||
...(form.proxyCommand.trim() ? { proxyCommand: form.proxyCommand.trim() } : {}),
|
||||
...(form.jumpHost.trim() ? { jumpHost: form.jumpHost.trim() } : {})
|
||||
|
|
@ -183,13 +173,11 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
|
|||
proxyCommand: target.proxyCommand ?? '',
|
||||
jumpHost: target.jumpHost ?? '',
|
||||
relayGracePeriodSeconds: String(
|
||||
target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
target.relayGracePeriodSeconds === 0
|
||||
? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
: (target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)
|
||||
),
|
||||
remoteWorkspaceSyncEnabled: target.remoteWorkspaceSyncEnabled === true,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: String(
|
||||
target.remoteWorkspaceSyncGracePeriodSeconds ??
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS
|
||||
)
|
||||
relayKeepAliveUntilReset: target.relayGracePeriodSeconds === 0
|
||||
})
|
||||
setShowForm(true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { FileKey } from 'lucide-react'
|
||||
import {
|
||||
DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS,
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
||||
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
} from '../../../../shared/ssh-types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
|
|
@ -18,8 +18,7 @@ export type EditingTarget = {
|
|||
proxyCommand: string
|
||||
jumpHost: string
|
||||
relayGracePeriodSeconds: string
|
||||
remoteWorkspaceSyncEnabled: boolean
|
||||
remoteWorkspaceSyncGracePeriodSeconds: string
|
||||
relayKeepAliveUntilReset: boolean
|
||||
}
|
||||
|
||||
export const EMPTY_FORM: EditingTarget = {
|
||||
|
|
@ -32,8 +31,7 @@ export const EMPTY_FORM: EditingTarget = {
|
|||
proxyCommand: '',
|
||||
jumpHost: '',
|
||||
relayGracePeriodSeconds: String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS),
|
||||
remoteWorkspaceSyncEnabled: false,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: String(DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS)
|
||||
relayKeepAliveUntilReset: false
|
||||
}
|
||||
|
||||
type SshTargetFormProps = {
|
||||
|
|
@ -136,60 +134,36 @@ export function SshTargetForm({
|
|||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Relay Grace Period (seconds)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.relayGracePeriodSeconds}
|
||||
type={form.relayKeepAliveUntilReset ? 'text' : 'number'}
|
||||
value={form.relayKeepAliveUntilReset ? 'Until reset' : form.relayGracePeriodSeconds}
|
||||
onChange={(e) =>
|
||||
onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value }))
|
||||
}
|
||||
placeholder={String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)}
|
||||
min={0}
|
||||
min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS}
|
||||
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
|
||||
disabled={form.relayKeepAliveUntilReset}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How long the relay keeps terminals alive after disconnect. Default: 10800 (3 hours). 0
|
||||
keeps it alive until terminals are ended or the relay is reset.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-3 border-t border-border/50 pt-3">
|
||||
<label className="flex items-start gap-3 text-sm">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 py-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4 accent-foreground"
|
||||
checked={form.remoteWorkspaceSyncEnabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-foreground"
|
||||
checked={form.relayKeepAliveUntilReset}
|
||||
onChange={(e) =>
|
||||
onFormChange((f) => ({ ...f, remoteWorkspaceSyncEnabled: e.target.checked }))
|
||||
onFormChange((f) => ({ ...f, relayKeepAliveUntilReset: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block font-medium">Sync remote workspace</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Store terminal tabs and split layouts on the SSH host so another Orca client can
|
||||
restore the same remote workspace.
|
||||
<span className="space-y-0.5">
|
||||
<span className="block font-medium text-foreground">Keep alive until reset</span>
|
||||
<span className="block text-muted-foreground">
|
||||
Remote terminals stay available until you end them or reset the relay.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{form.remoteWorkspaceSyncEnabled && (
|
||||
<div className="space-y-1.5 pl-7">
|
||||
<Label>Synced Relay Grace Period (seconds)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.remoteWorkspaceSyncGracePeriodSeconds}
|
||||
onChange={(e) =>
|
||||
onFormChange((f) => ({
|
||||
...f,
|
||||
remoteWorkspaceSyncGracePeriodSeconds: e.target.value
|
||||
}))
|
||||
}
|
||||
placeholder="0"
|
||||
min={0}
|
||||
max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How long synced remote workspace terminals stay alive after all clients disconnect.
|
||||
0 keeps them alive until explicitly terminated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How long the relay keeps terminals alive after disconnect. Default: 10800 (3 hours).
|
||||
Maximum: {MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} (7 days).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -105,13 +105,11 @@ function TargetRow({
|
|||
targetId,
|
||||
label,
|
||||
status,
|
||||
syncEnabled,
|
||||
syncStatus
|
||||
}: {
|
||||
targetId: string
|
||||
label: string
|
||||
status: SshConnectionStatus
|
||||
syncEnabled: boolean
|
||||
syncStatus: RemoteWorkspaceSyncStatus | undefined
|
||||
}): React.JSX.Element {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
|
@ -145,23 +143,17 @@ function TargetRow({
|
|||
<div className="truncate text-[12px] font-medium">{label}</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
{syncEnabled && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span
|
||||
className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`}
|
||||
>
|
||||
{syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? (
|
||||
<Loader2 className="size-2.5 shrink-0 animate-spin" />
|
||||
) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? (
|
||||
<AlertTriangle className="size-2.5 shrink-0" />
|
||||
) : (
|
||||
<Cloud className="size-2.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{syncStatusLabel(syncStatus)}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className={`inline-flex min-w-0 items-center gap-1 ${syncStatusTone(syncStatus)}`}>
|
||||
{syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? (
|
||||
<Loader2 className="size-2.5 shrink-0 animate-spin" />
|
||||
) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? (
|
||||
<AlertTriangle className="size-2.5 shrink-0" />
|
||||
) : (
|
||||
<Cloud className="size-2.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{syncStatusLabel(syncStatus)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{busy ? (
|
||||
|
|
@ -196,7 +188,6 @@ export function SshStatusSegment({
|
|||
}): React.JSX.Element | null {
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
|
||||
const sshTargetRemoteSyncEnabled = useAppStore((s) => s.sshTargetRemoteSyncEnabled)
|
||||
const remoteWorkspaceSyncStatusByTargetId = useAppStore(
|
||||
(s) => s.remoteWorkspaceSyncStatusByTargetId
|
||||
)
|
||||
|
|
@ -209,7 +200,6 @@ export function SshStatusSegment({
|
|||
id,
|
||||
label,
|
||||
status: (state?.status ?? 'disconnected') as SshConnectionStatus,
|
||||
syncEnabled: sshTargetRemoteSyncEnabled.get(id) === true,
|
||||
syncStatus: remoteWorkspaceSyncStatusByTargetId[id]
|
||||
}
|
||||
})
|
||||
|
|
@ -293,7 +283,6 @@ export function SshStatusSegment({
|
|||
targetId={t.id}
|
||||
label={t.label}
|
||||
status={t.status}
|
||||
syncEnabled={t.syncEnabled}
|
||||
syncStatus={t.syncStatus}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -363,9 +363,6 @@ async function applyRemoteWorkspaceSnapshot(
|
|||
|
||||
async function syncRemoteWorkspaceAfterConnect(targetId: string): Promise<void> {
|
||||
const store = useAppStore.getState()
|
||||
if (store.sshTargetRemoteSyncEnabled.get(targetId) !== true) {
|
||||
return
|
||||
}
|
||||
if (!(await prepareRemoteWorkspaceTarget(targetId))) {
|
||||
store.setRemoteWorkspaceSyncStatus(targetId, {
|
||||
phase: 'error',
|
||||
|
|
@ -1588,14 +1585,12 @@ export function useIpcEvents(): void {
|
|||
useAppStore.getState().setPortForwards(target.id, forwards)
|
||||
useAppStore.getState().setDetectedPorts(target.id, detected)
|
||||
}
|
||||
if (target.remoteWorkspaceSyncEnabled) {
|
||||
void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => {
|
||||
useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, {
|
||||
phase: 'error',
|
||||
message: err instanceof Error ? err.message : 'Workspace sync failed'
|
||||
})
|
||||
void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => {
|
||||
useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, {
|
||||
phase: 'error',
|
||||
message: err instanceof Error ? err.message : 'Workspace sync failed'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ export type SshSlice = {
|
|||
/** Maps target IDs to their user-facing labels. Populated during hydration
|
||||
* so components can look up labels without per-component IPC calls. */
|
||||
sshTargetLabels: Map<string, string>
|
||||
sshTargetRemoteSyncEnabled: Map<string, boolean>
|
||||
remoteWorkspaceHydratedTargetIds: Set<string>
|
||||
remoteWorkspaceSyncStatusByTargetId: Record<string, RemoteWorkspaceSyncStatus>
|
||||
sshCredentialQueue: SshCredentialRequest[]
|
||||
|
|
@ -46,9 +45,7 @@ export type SshSlice = {
|
|||
detectedPortsByConnection: Record<string, DetectedPort[]>
|
||||
setSshConnectionState: (targetId: string, state: SshConnectionState) => void
|
||||
setSshTargetLabels: (labels: Map<string, string>) => void
|
||||
setSshTargetsMetadata: (
|
||||
targets: Pick<SshTarget, 'id' | 'label' | 'remoteWorkspaceSyncEnabled'>[]
|
||||
) => void
|
||||
setSshTargetsMetadata: (targets: Pick<SshTarget, 'id' | 'label'>[]) => void
|
||||
markRemoteWorkspaceHydrated: (targetId: string) => void
|
||||
clearRemoteWorkspaceHydrated: (targetId: string) => void
|
||||
setRemoteWorkspaceSyncStatus: (targetId: string, status: RemoteWorkspaceSyncStatus) => void
|
||||
|
|
@ -62,7 +59,6 @@ export type SshSlice = {
|
|||
export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) => ({
|
||||
sshConnectionStates: new Map(),
|
||||
sshTargetLabels: new Map(),
|
||||
sshTargetRemoteSyncEnabled: new Map(),
|
||||
remoteWorkspaceHydratedTargetIds: new Set(),
|
||||
remoteWorkspaceSyncStatusByTargetId: {},
|
||||
sshCredentialQueue: [],
|
||||
|
|
@ -87,10 +83,7 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (set) =>
|
|||
setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }),
|
||||
setSshTargetsMetadata: (targets) =>
|
||||
set({
|
||||
sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])),
|
||||
sshTargetRemoteSyncEnabled: new Map(
|
||||
targets.map((target) => [target.id, target.remoteWorkspaceSyncEnabled === true])
|
||||
)
|
||||
sshTargetLabels: new Map(targets.map((target) => [target.id, target.label]))
|
||||
}),
|
||||
markRemoteWorkspaceHydrated: (targetId) =>
|
||||
set((s) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
// ─── SSH Connection Types ───────────────────────────────────────────
|
||||
|
||||
export const MIN_SSH_RELAY_GRACE_PERIOD_SECONDS = 60
|
||||
export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60
|
||||
export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 7 * 24 * 60 * 60
|
||||
export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60
|
||||
export const DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS =
|
||||
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
|
||||
|
||||
export type SshTarget = {
|
||||
id: string
|
||||
|
|
@ -21,15 +19,8 @@ export type SshTarget = {
|
|||
/** Jump host (ProxyJump), if any. */
|
||||
jumpHost?: string
|
||||
/** Grace period in seconds before relay shuts down after disconnect.
|
||||
* 0 disables expiry. Default: 10800 (3 hours). */
|
||||
* 0 disables expiry. Default: 10800 (3 hours). Max: 604800 (7 days). */
|
||||
relayGracePeriodSeconds?: number
|
||||
/** Opt in to remote-host-owned workspace/session state for this SSH target.
|
||||
* Classic SSH remains local-session-backed when this is false/absent. */
|
||||
remoteWorkspaceSyncEnabled?: boolean
|
||||
/** Grace period in seconds for synced remote workspace relays.
|
||||
* 0 disables expiry. Default: 10800 (3 hours). Only applies when
|
||||
* remoteWorkspaceSyncEnabled is true. */
|
||||
remoteWorkspaceSyncGracePeriodSeconds?: number
|
||||
/** Set to true after a successful connection that triggered a credential
|
||||
* prompt (passphrase or password). Persisted so startup reconnect can
|
||||
* partition targets into eager (no passphrase) vs deferred (passphrase)
|
||||
|
|
|
|||
Loading…
Reference in New Issue