Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ Changing profiles or connection modes is a soft workspace switch, not another
cold boot. The shell and current management overlay remain mounted while
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
new connection repopulates skeletons. This prevents rows or transcripts from
the previous gateway bleeding into the next one.
the previous gateway bleeding into the next one. Switching changes only the
foreground view and request route: it does not cancel turns or stop a backend,
and retained background sockets continue receiving events from running jobs.

### Verification

Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/electron/connection-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,13 @@ test('apiRequestRegistryConnectionId extracts a genuinely non-local connection i
assert.equal(apiRequestRegistryConnectionId({ connectionId: ' gw-1 ', path: '/x' }), 'gw-1')
})

test('apiRequestRegistryConnectionId resolves null for the legacy/local routes', () => {
test('apiRequestRegistryConnectionId preserves an explicit local registry route', () => {
assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), 'local')
})

test('apiRequestRegistryConnectionId resolves null for unscoped legacy routes', () => {
assert.equal(apiRequestRegistryConnectionId({ path: '/api/cron/jobs' }), null)
assert.equal(apiRequestRegistryConnectionId({ connectionId: '', path: '/x' }), null)
assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), null)
assert.equal(apiRequestRegistryConnectionId({ connectionId: null, path: '/x' }), null)
assert.equal(apiRequestRegistryConnectionId(null), null)
assert.equal(apiRequestRegistryConnectionId(undefined), null)
Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/electron/connection-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,15 +750,16 @@ function pathWithProfileScope(path, profile) {

/**
* Registry connection a REST request is explicitly pinned to, or null for the
* legacy profile-routed path. `''`/`'local'` mean the local pool — callers
* only detour through the registry for a genuinely non-local connection, so
* single-source users keep the byte-identical v1 route.
* legacy profile-routed path. An explicit `local` id must stay registry-scoped:
* when the v1 route is remote, only the registry resolver can force the request
* back to this device. Single-source users omit the id and keep the
* byte-identical v1 route.
*/
function apiRequestRegistryConnectionId(request): null | string {
const raw = request && typeof request === 'object' ? (request as { connectionId?: unknown }).connectionId : ''
const id = String(raw ?? '').trim()

if (!id || id === 'local') {
if (!id) {
return null
}

Expand Down
83 changes: 82 additions & 1 deletion apps/desktop/electron/connection-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
REGISTRY_VERSION,
rememberSshEnumeration,
removeConnection,
resolvedConnectionId,
resolveRegistryLocalRoute,
setConnectionLaunchMode,
setLastUsedConnection,
setPrimaryConnection,
shouldDeferLocalEnumeration,
shouldRetrySshInventory,
Expand All @@ -54,6 +57,49 @@ test('labelSlug kebab-cases and never returns empty for non-empty input', () =>
assert.equal(labelSlug('!!!'), 'connection')
})

test('resolvedConnectionId identifies local and migrated remote descriptors', () => {
const registry = migrateV1ToRegistry({
mode: 'local',
profiles: {
personal: { mode: 'remote', url: 'https://personal.example:9443/', authMode: 'token' },
work: { mode: 'ssh', host: 'work-host', user: 'root' }
}
})

const personal = registry.connections.find(connection => connection.kind === 'remote')
const work = registry.connections.find(connection => connection.kind === 'ssh')

assert.equal(resolvedConnectionId(registry, { mode: 'local' }), LOCAL_CONNECTION_ID)
assert.equal(
resolvedConnectionId(registry, {
baseUrl: 'https://personal.example:9443',
mode: 'remote',
remoteKind: 'url'
}),
personal?.id
)
assert.equal(
resolvedConnectionId(registry, {
baseUrl: 'http://127.0.0.1:49152',
mode: 'remote',
remoteHost: 'root@work-host',
remoteKind: 'ssh'
}),
work?.id
)
})

test('resolvedConnectionId does not guess an unregistered remote', () => {
assert.equal(
resolvedConnectionId(emptyRegistry(), {
baseUrl: 'https://unknown.example',
mode: 'remote',
remoteKind: 'url'
}),
null
)
})

test('agentHandle bare when unique, @name-device shape when duplicated', () => {
assert.equal(agentHandle('research', 'Homelab', false), 'research')
assert.equal(agentHandle('research', 'Homelab', true), 'research-homelab')
Expand Down Expand Up @@ -644,6 +690,8 @@ test('normalizeRegistry degrades junk to a local-only registry', () => {

assert.equal(registry.version, REGISTRY_VERSION)
assert.equal(registry.primary, LOCAL_CONNECTION_ID)
assert.equal(registry.launchMode, 'primary')
assert.equal(registry.lastUsed, LOCAL_CONNECTION_ID)
assert.equal(registry.connections.length, 1)
assert.equal(registry.connections[0].kind, 'local')
}
Expand Down Expand Up @@ -675,6 +723,8 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () =>
const input = {
version: 2,
primary: 'homelab',
launchMode: 'last-used',
lastUsed: 'homelab',
connections: [
{ id: 'local', kind: 'local', label: 'This device' },
{
Expand All @@ -700,6 +750,8 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () =>
const registry = normalizeRegistry(input)

assert.equal(registry.primary, 'homelab')
assert.equal(registry.launchMode, 'last-used')
assert.equal(registry.lastUsed, 'homelab')
assert.equal(registry.connections.length, 4)
assert.deepEqual(
registry.connections.map(c => c.id),
Expand All @@ -709,6 +761,22 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () =>
assert.equal(registry.connections[3].port, 2222)
})

test('normalizeRegistry falls back to Primary when the last-used source is missing', () => {
const registry = normalizeRegistry({
version: 2,
primary: 'homelab',
launchMode: 'last-used',
lastUsed: 'retired-host',
connections: [
{ id: 'local', kind: 'local', label: 'This device' },
{ id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }
]
})

assert.equal(registry.launchMode, 'last-used')
assert.equal(registry.lastUsed, 'homelab')
})

// --- v1 → v2 migration ---

test('migrate: v1 local-only config → local-only registry', () => {
Expand Down Expand Up @@ -793,17 +861,19 @@ test('migrate: duplicate host labels are suffixed, not dropped', () => {

// --- registry operations ---

test('removeConnection: local refuses, primary retargets to local', () => {
test('removeConnection: local refuses, primary and last-used retarget safely', () => {
let registry = emptyRegistry()
const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
registry = upsertConnection(registry, entry)
registry = setPrimaryConnection(registry, entry.id)
registry = setLastUsedConnection(registry, entry.id)

assert.throws(() => removeConnection(registry, LOCAL_CONNECTION_ID), /cannot be removed/)

const after = removeConnection(registry, entry.id)

assert.equal(after.primary, LOCAL_CONNECTION_ID)
assert.equal(after.lastUsed, LOCAL_CONNECTION_ID)
assert.equal(after.connections.length, 1)
// Removing an unknown id is a no-op, not an error.
assert.equal(removeConnection(after, 'ghost'), after)
Expand All @@ -816,6 +886,17 @@ test('setPrimaryConnection validates the target id', () => {
assert.equal(setPrimaryConnection(registry, LOCAL_CONNECTION_ID).primary, LOCAL_CONNECTION_ID)
})

test('last-used source and launch mode validate their persisted values', () => {
let registry = emptyRegistry()
const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
registry = upsertConnection(registry, entry)

assert.throws(() => setLastUsedConnection(registry, 'ghost'), /No connection/)
assert.equal(setLastUsedConnection(registry, entry.id).lastUsed, entry.id)
assert.equal(setConnectionLaunchMode(registry, 'last-used').launchMode, 'last-used')
assert.throws(() => setConnectionLaunchMode(registry, 'sometimes'), /Unknown connection launch mode/)
})

test('upsertConnection replaces by id and appends new ids', () => {
let registry = emptyRegistry()
const a = normalizeConnectionInput({ kind: 'remote', label: 'A', url: 'http://a:1' }, registry)
Expand Down
111 changes: 107 additions & 4 deletions apps/desktop/electron/connection-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export interface ConnectionRegistry {
version: typeof REGISTRY_VERSION
/** id of the connection that owns the window/primary backend. */
primary: string
/** Which saved source Sessions should restore when the app launches. */
launchMode: 'last-used' | 'primary'
/** Last source the Sessions workspace successfully opened. Additive in v2
* so registries written before multi-source switching still normalize. */
lastUsed: string
connections: RegistryConnection[]
}

Expand Down Expand Up @@ -178,6 +183,79 @@ export interface RegistryLocalRoute {
poolKey: string
}

export interface ResolvedConnectionDescriptor {
baseUrl?: string
mode?: 'local' | 'remote'
remoteHost?: string
remoteKind?: 'cloud' | 'ssh' | 'url'
}

/**
* Recover registry identity for a descriptor resolved through the legacy v1
* profile path. Registry-scoped routes already carry `connectionId`; this
* bridge keeps migrated per-profile remotes truthful until v1 is retired.
*/
export function resolvedConnectionId(
registry: ConnectionRegistry,
descriptor: ResolvedConnectionDescriptor
): null | string {
if (descriptor.mode === 'local') {
return registry.connections.find(connection => connection.kind === 'local')?.id ?? null
}

if (descriptor.mode !== 'remote') {
return null
}

if (descriptor.remoteKind === 'ssh') {
const remoteHost = String(descriptor.remoteHost || '')
.trim()
.toLowerCase()

if (!remoteHost) {
return null
}

return (
registry.connections.find(connection => {
if (connection.kind !== 'ssh') {
return false
}

const host = String(connection.host || '')
.trim()
.toLowerCase()

const target = connection.user ? `${String(connection.user).trim().toLowerCase()}@${host}` : host

return target === remoteHost
})?.id ?? null
)
}

let baseUrl = ''

try {
baseUrl = normalizeRemoteBaseUrl(descriptor.baseUrl)
} catch {
return null
}

return (
registry.connections.find(connection => {
if (connection.kind !== 'cloud' && connection.kind !== 'remote') {
return false
}

try {
return normalizeRemoteBaseUrl(connection.url) === baseUrl
} catch {
return false
}
})?.id ?? null
)
}

/**
* How the registry's 'local' entry resolves a backend for `profile`.
*
Expand Down Expand Up @@ -818,11 +896,15 @@ export function normalizeRegistry(raw: unknown): ConnectionRegistry {
connections.unshift(localEntry())
}

const primary = String(parsed.primary || '').trim()
const storedPrimary = String(parsed.primary || '').trim()
const primary = connections.some(c => c.id === storedPrimary) ? storedPrimary : LOCAL_CONNECTION_ID
const storedLastUsed = String(parsed.lastUsed || '').trim()

return {
version: REGISTRY_VERSION,
primary: connections.some(c => c.id === primary) ? primary : LOCAL_CONNECTION_ID,
primary,
launchMode: parsed.launchMode === 'last-used' ? 'last-used' : 'primary',
lastUsed: connections.some(c => c.id === storedLastUsed) ? storedLastUsed : primary,
connections
}
}
Expand Down Expand Up @@ -967,7 +1049,7 @@ export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry {
}
}

return { version: REGISTRY_VERSION, primary, connections }
return { version: REGISTRY_VERSION, primary, launchMode: 'primary', lastUsed: primary, connections }
}

/** Insert or replace by id. Input must already be normalized/validated. */
Expand All @@ -994,9 +1076,12 @@ export function removeConnection(registry: ConnectionRegistry, id: string): Conn
throw new Error('The local connection cannot be removed.')
}

const primary = registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary

return {
...registry,
primary: registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary,
primary,
lastUsed: registry.lastUsed === id ? primary : registry.lastUsed,
connections: registry.connections.filter(c => c.id !== id)
}
}
Expand All @@ -1009,3 +1094,21 @@ export function setPrimaryConnection(registry: ConnectionRegistry, id: string):

return { ...registry, primary: id }
}

/** Remember the last source the Sessions workspace opened successfully. */
export function setLastUsedConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry {
if (!registry.connections.some(c => c.id === id)) {
throw new Error(`No connection with id "${id}".`)
}

return { ...registry, lastUsed: id }
}

/** Choose whether launch restores the explicit primary or the last-used source. */
export function setConnectionLaunchMode(registry: ConnectionRegistry, launchMode: string): ConnectionRegistry {
if (launchMode !== 'last-used' && launchMode !== 'primary') {
throw new Error(`Unknown connection launch mode "${String(launchMode)}".`)
}

return { ...registry, launchMode }
}
Loading
Loading