Files
goodbuddy/src/main/remote-agent/remote-project-save-service.ts
T
mesalogo 459cc21e3d
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
fix: make managed SSH sessions fast and resilient
Managed SSH project selection repeatedly activated the remote Host and could
leave idle ACP bindings rejected as stale-controller after a controller
generation change. Host environment updates also coupled project state to
component installation evidence and presented a crowded control surface.

Project selection is now local-only, one Host connection serves compatible
project and conversation callers, idle bindings are safely replaced only
after proven controller takeover, and active prompts keep exact resume/replay.
Remote environment setup now uses explicit signed compound package
preparation with bounded transfer, rollback, recovery, and clearer settings.

Managed Hosts must be updated to separately distributed Agent 0.11.6 before
the daemon-side controller recovery fix takes effect. Desktop packages do not
embed the Agent or remote Runtime, and existing project data is preserved.

Release note: 托管 SSH 项目切换不再重复连接,同一 Host 可顺畅承载多个项目和对话;同时修复重连后的 stale-controller,并优化远程环境安装、更新与状态布局。
2026-08-29 10:58:19 +08:00

547 lines
16 KiB
TypeScript

import type { AssistantProject } from '../../shared/assistant-contracts'
import {
remoteProjectSaveRequestSchema,
type RemoteProjectSavePhase,
type RemoteProjectSaveProgress,
type RemoteProjectSaveRequest
} from '../../shared/remote-project-candidate-contracts'
import { sha256DigestSchema } from '../../shared/agent-protocol/contracts'
import {
REMOTE_WORKSPACE_READ_CAPABILITIES,
remoteWorkspaceValidateResultSchema,
type RemoteWorkspaceHandle
} from '../../shared/remote-agent-contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type {
AssistantDatabase,
SshProjectWrite
} from '../assistant/assistant-database'
import type {
SshConnectionTarget,
SshHostStore
} from '../ssh/ssh-host-store'
import {
assertValidSshConnectionTarget,
toCurrentSshConnectionTarget
} from '../ssh/ssh-connection-target'
import type {
AgentInstallationIdentity,
AgentInstallationManager
} from './agent-installation-manager'
import {
type RemoteAgentConnection,
type RemoteAgentConnectionManager
} from './remote-agent-connection-manager'
const CLEANUP_TIMEOUT_MS = 5_000
const WORKSPACE_READ_CAPABILITY = {
name: 'workspace/read',
minimumVersion: 1,
critical: true
} as const
export interface RemoteProjectSaveOwner {
readonly id: number
isDestroyed(): boolean
on(event: 'destroyed', listener: () => void): unknown
removeListener(event: 'destroyed', listener: () => void): unknown
}
export type RemoteProjectRuntimeValidationInput = Readonly<{
selection: AgentRuntimeSelection
host: Readonly<{
hostId: string
hostRevision: number
hostKeyGeneration: number
remoteUsername: string
}>
agent: Readonly<{
installationId: AgentInstallationIdentity['installationId']
binaryDigest: string
version: string
architecture: 'x64' | 'arm64'
protocolMajor: number
}>
connection: RemoteAgentConnection
signal: AbortSignal
}>
export interface RemoteProjectRuntimeValidationLease {
assertCurrent(): void
release(): void
}
export interface RemoteProjectRuntimeValidator {
validate(
input: RemoteProjectRuntimeValidationInput
): Promise<RemoteProjectRuntimeValidationLease>
}
export class UnavailableRemoteProjectRuntimeValidator
implements RemoteProjectRuntimeValidator
{
async validate(): Promise<never> {
throw new Error('Remote Runtime validation is unavailable')
}
}
type ActiveSave = {
controller: AbortController
done: Promise<void>
resolveDone: () => void
destroyedListener: () => void
connection?: RemoteAgentConnection
workspace?: RemoteWorkspaceHandle
runtimeLease?: RemoteProjectRuntimeValidationLease
}
type RemoteProjectPreparationDraft = Readonly<{
hostId: string
remoteRootPath: string
runtimeSelection: AgentRuntimeSelection
}>
type PreparedRemoteProject = Readonly<{
runtimeSelection: AgentRuntimeSelection
target: SshConnectionTarget
installation: AgentInstallationIdentity
connection: RemoteAgentConnection
workspace: RemoteWorkspaceHandle
runtimeLease: RemoteProjectRuntimeValidationLease
}>
export type RemoteProjectSaveServiceOptions = {
database: Pick<
AssistantDatabase,
| 'getProject'
| 'createSshProject'
| 'updateSshProject'
>
sshHosts: Pick<
SshHostStore,
'resolveConnectionTarget' | 'assertConnectionTargetCurrent'
>
installationManager: Pick<AgentInstallationManager, 'activateInstalled'>
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
resolveRuntimeSelection(
selection: AgentRuntimeSelection
): Promise<AgentRuntimeSelection>
runtimeValidator?: RemoteProjectRuntimeValidator
notify?: (
owner: RemoteProjectSaveOwner,
progress: RemoteProjectSaveProgress
) => void
cleanupTimeoutMs?: number
}
export class RemoteProjectSaveService {
readonly #database: RemoteProjectSaveServiceOptions['database']
readonly #sshHosts: RemoteProjectSaveServiceOptions['sshHosts']
readonly #installationManager: RemoteProjectSaveServiceOptions['installationManager']
readonly #connectionManager: RemoteProjectSaveServiceOptions['connectionManager']
readonly #resolveRuntimeSelection: RemoteProjectSaveServiceOptions['resolveRuntimeSelection']
readonly #runtimeValidator: RemoteProjectRuntimeValidator
readonly #notify?: RemoteProjectSaveServiceOptions['notify']
readonly #cleanupTimeoutMs: number
readonly #active = new Map<RemoteProjectSaveOwner, ActiveSave>()
#disposed = false
constructor(options: RemoteProjectSaveServiceOptions) {
this.#database = options.database
this.#sshHosts = options.sshHosts
this.#installationManager = options.installationManager
this.#connectionManager = options.connectionManager
this.#resolveRuntimeSelection = options.resolveRuntimeSelection
this.#runtimeValidator =
options.runtimeValidator ??
new UnavailableRemoteProjectRuntimeValidator()
this.#notify = options.notify
this.#cleanupTimeoutMs =
options.cleanupTimeoutMs ?? CLEANUP_TIMEOUT_MS
if (
!Number.isSafeInteger(this.#cleanupTimeoutMs) ||
this.#cleanupTimeoutMs < 1
) {
throw new RangeError('Invalid remote project cleanup timeout')
}
}
async save(
owner: RemoteProjectSaveOwner,
input: unknown
): Promise<AssistantProject> {
this.#assertOwnerAvailable(owner)
if (this.#active.has(owner)) {
throw new Error('A remote project save is already in progress')
}
const request = remoteProjectSaveRequestSchema.parse(input)
const active = this.#start(owner)
try {
return await this.#performSave(owner, active, request)
} finally {
await this.#finish(owner, active)
}
}
cancelCurrent(owner: RemoteProjectSaveOwner): void {
this.#active.get(owner)?.controller.abort(
new DOMException('Remote project save cancelled', 'AbortError')
)
}
async dispose(): Promise<void> {
if (this.#disposed) return
this.#disposed = true
const saves = [...this.#active.entries()]
for (const [, active] of saves) {
active.controller.abort(
new DOMException('Remote project service disposed', 'AbortError')
)
}
await Promise.allSettled(saves.map(([, active]) => active.done))
}
async #performSave(
owner: RemoteProjectSaveOwner,
active: ActiveSave,
request: RemoteProjectSaveRequest
): Promise<AssistantProject> {
const expectedUpdatedAt =
request.intent === 'update'
? this.#readUpdateRevision(request)
: undefined
const prepared = await this.#prepare(
owner,
active,
request.draft
)
this.#progress(owner, 'saving')
active.controller.signal.throwIfAborted()
const write = this.#projectWrite(request, prepared)
return request.intent === 'create'
? this.#database.createSshProject(write)
: this.#database.updateSshProject(
request.draft.projectId,
expectedUpdatedAt!,
write
)
}
async #prepare(
owner: RemoteProjectSaveOwner,
active: ActiveSave,
draft: RemoteProjectPreparationDraft
): Promise<PreparedRemoteProject> {
const { signal } = active.controller
const runtimeSelection = await this.#resolveRuntimeSelection(
draft.runtimeSelection
)
signal.throwIfAborted()
this.#progress(owner, 'host')
const target = await this.#sshHosts.resolveConnectionTarget(
draft.hostId
)
signal.throwIfAborted()
assertValidSshConnectionTarget(
draft.hostId,
target,
'SSH Host binding is unavailable'
)
this.#progress(owner, 'agent')
const installation =
await this.#installationManager.activateInstalled(
draft.hostId,
{ signal }
)
signal.throwIfAborted()
assertInstallation(installation)
const connection = await this.#connectionManager.acquire(
draft.hostId,
{
...installation,
requiredCapabilities: [WORKSPACE_READ_CAPABILITY]
},
signal
)
active.connection = connection
signal.throwIfAborted()
assertConnectionIdentity(target, installation, connection)
this.#progress(owner, 'workspace')
const validated = remoteWorkspaceValidateResultSchema.parse(
await connection.client.request(
'workspace/validate',
{
remoteRootPath: draft.remoteRootPath,
requestedAccess: 'read-only',
requiredCapabilities: [
...REMOTE_WORKSPACE_READ_CAPABILITIES
]
},
{ signal }
)
)
active.workspace = validated.handle
signal.throwIfAborted()
assertWorkspace(validated.handle)
if (
validated.handle.canonicalDisplayPath !== draft.remoteRootPath
) {
throw new Error('Remote workspace path changed during validation')
}
assertCriticalWorkspaceRead(connection)
this.#progress(owner, 'runtime')
const runtimeLease = await this.#runtimeValidator.validate({
selection: runtimeSelection,
host: {
hostId: target.host.id,
hostRevision: target.hostRevision,
hostKeyGeneration: target.hostKeyGeneration,
remoteUsername: target.host.username
},
agent: {
installationId: installation.installationId,
binaryDigest: installation.binaryDigest,
version: installation.agentVersion,
architecture: installation.architecture,
protocolMajor: installation.protocol.major
},
connection,
signal
})
active.runtimeLease = runtimeLease
signal.throwIfAborted()
runtimeLease.assertCurrent()
return {
runtimeSelection,
target,
installation,
connection,
workspace: validated.handle,
runtimeLease
}
}
#readUpdateRevision(
request: Extract<RemoteProjectSaveRequest, { intent: 'update' }>
): string {
const project = this.#database.getProject(request.draft.projectId)
if (
project.kind !== 'user' ||
project.channel !== undefined ||
project.executionSpace.kind !== 'ssh' ||
project.executionSpace.hostId !== request.draft.hostId ||
project.executionSpace.remoteRootPath !==
request.draft.remoteRootPath
) {
throw new Error(
'Only an existing user SSH project with the same Host and root can be updated'
)
}
return project.updatedAt
}
#projectWrite(
request: RemoteProjectSaveRequest,
prepared: PreparedRemoteProject
): SshProjectWrite {
const {
runtimeSelection,
target,
installation,
connection,
workspace,
runtimeLease
} = prepared
return {
project: {
name: request.draft.name,
description: request.draft.description,
rootPath: workspace.canonicalDisplayPath,
defaultWorkMode: request.draft.defaultWorkMode,
runtimeSelection
},
executionSpace: {
kind: 'ssh',
hostId: target.host.id,
remoteRootPath: workspace.canonicalDisplayPath
},
assertCurrent: () => {
this.#sshHosts.assertConnectionTargetCurrent(
toCurrentSshConnectionTarget(target)
)
assertConnectionIdentity(target, installation, connection)
assertCriticalWorkspaceRead(connection)
runtimeLease.assertCurrent()
}
}
}
#start(owner: RemoteProjectSaveOwner): ActiveSave {
const controller = new AbortController()
let resolveDone!: () => void
const active: ActiveSave = {
controller,
done: new Promise<void>((resolve) => {
resolveDone = resolve
}),
resolveDone,
destroyedListener: () => {
controller.abort(
new DOMException(
'Remote project owner was destroyed',
'AbortError'
)
)
}
}
this.#active.set(owner, active)
owner.on('destroyed', active.destroyedListener)
return active
}
async #finish(
owner: RemoteProjectSaveOwner,
active: ActiveSave
): Promise<void> {
try {
await this.#cleanup(active)
} finally {
owner.removeListener('destroyed', active.destroyedListener)
if (this.#active.get(owner) === active) {
this.#active.delete(owner)
}
active.resolveDone()
}
}
async #cleanup(active: ActiveSave): Promise<void> {
try {
active.runtimeLease?.release()
} catch {
// Continue releasing independently owned resources.
}
const workspace = active.workspace
const connection = active.connection
if (workspace && connection) {
try {
await connection.client.request(
'workspace/close',
{
workspaceId: workspace.workspaceId,
generation: workspace.generation
},
{ signal: AbortSignal.timeout(this.#cleanupTimeoutMs) }
)
} catch {
// The connection may already be offline after cancellation.
}
}
try {
connection?.release()
} catch {
// A concurrently closed connection is already released.
}
}
#progress(
owner: RemoteProjectSaveOwner,
phase: RemoteProjectSavePhase
): void {
if (!this.#notify || owner.isDestroyed()) return
try {
this.#notify(owner, { phase })
} catch {
// Renderer progress observers cannot affect the save.
}
}
#assertOwnerAvailable(owner: RemoteProjectSaveOwner): void {
if (this.#disposed) {
throw new Error('Remote project save service is disposed')
}
if (
!owner ||
!Number.isSafeInteger(owner.id) ||
owner.id < 0 ||
owner.isDestroyed()
) {
throw new Error('Remote project owner is unavailable')
}
}
}
function assertInstallation(
installation: AgentInstallationIdentity
): void {
if (
!/^[A-Za-z0-9](?:[A-Za-z0-9._:-]{0,127})$/u.test(
installation.installationId
) ||
!sha256DigestSchema.safeParse(installation.binaryDigest).success ||
installation.platform !== 'linux' ||
!['x64', 'arm64'].includes(installation.architecture) ||
installation.supervisor !== 'detached-on-demand'
) {
throw new Error('Remote Agent installation is incompatible')
}
}
function assertConnectionIdentity(
target: SshConnectionTarget,
installation: Pick<
AgentInstallationIdentity,
'installationId' | 'binaryDigest'
>,
connection: RemoteAgentConnection
): void {
if (
connection.state !== 'ready' ||
connection.identity.hostId !== target.host.id ||
connection.identity.hostRevision !== target.hostRevision ||
connection.identity.hostKeyGeneration !== target.hostKeyGeneration ||
connection.identity.remoteUsername !== target.host.username ||
connection.identity.installationId !== installation.installationId ||
connection.identity.binaryDigest !== installation.binaryDigest ||
connection.status.state !== 'ready' ||
connection.status.draining ||
connection.status.installationId !== installation.installationId ||
connection.status.binaryDigest !== installation.binaryDigest
) {
throw new Error('Remote Agent identity is incompatible')
}
}
function assertCriticalWorkspaceRead(
connection: RemoteAgentConnection
): void {
const capability = connection.capabilities.capabilities.find(
(entry) => entry.name === WORKSPACE_READ_CAPABILITY.name
)
if (
!capability ||
capability.version < WORKSPACE_READ_CAPABILITY.minimumVersion ||
!capability.critical
) {
throw new Error('Remote Agent lacks workspace read capability')
}
}
function assertWorkspace(handle: RemoteWorkspaceHandle): void {
if (
handle.access !== 'read-only' ||
REMOTE_WORKSPACE_READ_CAPABILITIES.some(
(capability) => !handle.capabilities.includes(capability)
) ||
handle.capabilities.includes('write-text-atomic') ||
handle.capabilities.includes('apply-change-set')
) {
throw new Error('Remote workspace validation is incompatible')
}
}