Files
goodbuddy/src/agent-daemon/runtime-owner-registry.ts
T
mesalogo 607bb33249 feat: add managed SSH execution spaces
Add managed SSH Hosts and projects with signed side-by-side GoodBuddy Agent and OpenCode Runtime installation, Ask and Execute workspace access, a Main-owned model bridge, detached Agent lifecycle, durable ACP output recovery, and project activation UI.

Use protocol v2 with bounded FIFO frames over SSH instead of custom credit, priority, batching, or model-message chunking. Mismatched Host Agents now upgrade automatically, an exactly verified Node runtime is reused on-host, and local Portable builds can embed strictly verified Agent artifacts.

Release note: 新增托管 SSH 远程项目,可自动安装或升级签名 Agent/OpenCode Runtime,并在 SSH 断线后继续 Agent 工作和同步未确认输出。
2026-08-24 23:02:18 +08:00

233 lines
7.1 KiB
TypeScript

import type { DatabaseSync, StatementSync } from 'node:sqlite'
import { agentIdentifierSchema } from '../shared/agent-protocol/contracts'
import { openPrivateSqliteDatabase } from '../shared/node/private-sqlite-database'
import {
parseSerializedLinuxRuntimeProcessIdentity,
serializeLinuxRuntimeProcessIdentity,
type LinuxRuntimeProcessIdentity
} from './runtime-process-identity'
export type RuntimeOwnerState = 'reserved' | 'running' | 'stopping'
export type RuntimeOwnerRecord = {
ownerId: string
launchId: string
processId: string
installationId: string
ownerToken: string
state: RuntimeOwnerState
processIdentity?: LinuxRuntimeProcessIdentity
createdAt: number
updatedAt: number
}
type RuntimeOwnerRow = {
owner_id: string
launch_id: string
process_id: string
installation_id: string
owner_token: string
state: RuntimeOwnerState
process_identity_json: string | null
created_at: number
updated_at: number
}
export class RuntimeOwnerRegistry {
readonly #database: DatabaseSync
readonly #now: () => number
readonly #statements: {
insert: StatementSync
get: StatementSync
listInstallation: StatementSync
transition: StatementSync
remove: StatementSync
}
constructor(path: string, options: { now?: () => number } = {}) {
this.#now = options.now ?? Date.now
this.#database = openPrivateSqliteDatabase(path)
this.#database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS active_runtime_owners (
owner_id TEXT PRIMARY KEY,
launch_id TEXT NOT NULL UNIQUE,
process_id TEXT NOT NULL UNIQUE,
installation_id TEXT NOT NULL,
owner_token TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('reserved', 'running', 'stopping')),
process_identity_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS active_runtime_owners_installation
ON active_runtime_owners(installation_id, created_at);
`)
this.#migrateLegacyRecords()
this.#statements = {
insert: this.#database.prepare(`
INSERT INTO active_runtime_owners (
owner_id, launch_id, process_id, installation_id, owner_token,
state, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'reserved', ?, ?)
`),
get: this.#database.prepare(
'SELECT * FROM active_runtime_owners WHERE owner_id = ?'
),
listInstallation: this.#database.prepare(`
SELECT * FROM active_runtime_owners
WHERE installation_id = ?
ORDER BY created_at, owner_id
`),
transition: this.#database.prepare(`
UPDATE active_runtime_owners
SET state = ?, process_identity_json = ?, updated_at = ?
WHERE owner_id = ? AND state = ?
`),
remove: this.#database.prepare(`
DELETE FROM active_runtime_owners WHERE owner_id = ? AND state = ?
`)
}
}
reserve(input: Omit<
RuntimeOwnerRecord,
'state' | 'processIdentity' | 'createdAt' | 'updatedAt'
>): RuntimeOwnerRecord {
const record = validateReservation(input)
const now = this.#now()
this.#statements.insert.run(
record.ownerId,
record.launchId,
record.processId,
record.installationId,
record.ownerToken,
now,
now
)
return { ...record, state: 'reserved', createdAt: now, updatedAt: now }
}
get(ownerIdInput: string): RuntimeOwnerRecord | undefined {
const ownerId = agentIdentifierSchema.parse(ownerIdInput)
const row = this.#statements.get.get(ownerId) as RuntimeOwnerRow | undefined
return row === undefined ? undefined : decodeRow(row)
}
listForInstallation(installationIdInput: string): RuntimeOwnerRecord[] {
const installationId = agentIdentifierSchema.parse(installationIdInput)
return (
this.#statements.listInstallation.all(installationId) as RuntimeOwnerRow[]
).map(decodeRow)
}
markRunning(
ownerId: string,
identity: LinuxRuntimeProcessIdentity
): RuntimeOwnerRecord {
return this.#transition(ownerId, 'reserved', 'running', identity)
}
markStopping(ownerId: string): RuntimeOwnerRecord {
return this.#transition(ownerId, 'running', 'stopping')
}
remove(ownerIdInput: string, from: RuntimeOwnerState): void {
const ownerId = agentIdentifierSchema.parse(ownerIdInput)
if (this.#statements.remove.run(ownerId, from).changes !== 1) {
throw new Error(`Runtime owner transition conflict: expected ${from}`)
}
}
close(): void {
this.#database.close()
}
#transition(
ownerIdInput: string,
from: RuntimeOwnerState,
to: RuntimeOwnerState,
identity?: LinuxRuntimeProcessIdentity
): RuntimeOwnerRecord {
const ownerId = agentIdentifierSchema.parse(ownerIdInput)
const existing = this.get(ownerId)
if (existing === undefined || existing.state !== from) {
throw new Error(`Runtime owner transition conflict: expected ${from}`)
}
const processIdentity = identity ?? existing.processIdentity
if (
this.#statements.transition.run(
to,
processIdentity === undefined
? null
: serializeLinuxRuntimeProcessIdentity(processIdentity),
this.#now(),
ownerId,
from
).changes !== 1
) {
throw new Error('Runtime owner transition conflict')
}
return this.get(ownerId)!
}
#migrateLegacyRecords(): void {
const legacy = this.#database.prepare(`
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = 'runtime_process_owners'
`).get()
if (legacy === undefined) return
this.#database.exec(`
BEGIN IMMEDIATE;
INSERT OR IGNORE INTO active_runtime_owners (
owner_id, launch_id, process_id, installation_id, owner_token,
state, process_identity_json, created_at, updated_at
)
SELECT owner_id, launch_id, process_id, installation_id, owner_token,
state, process_identity_json, created_at, updated_at
FROM runtime_process_owners
WHERE state IN ('reserved', 'running', 'stopping');
DROP TABLE runtime_process_owners;
COMMIT;
`)
}
}
function validateReservation(
input: Omit<
RuntimeOwnerRecord,
'state' | 'processIdentity' | 'createdAt' | 'updatedAt'
>
): typeof input {
agentIdentifierSchema.parse(input.ownerId)
agentIdentifierSchema.parse(input.launchId)
agentIdentifierSchema.parse(input.processId)
agentIdentifierSchema.parse(input.installationId)
if (!/^[a-f0-9]{32}$/u.test(input.ownerToken)) {
throw new Error('Runtime owner token marker is malformed')
}
return { ...input }
}
function decodeRow(row: RuntimeOwnerRow): RuntimeOwnerRecord {
return {
ownerId: row.owner_id,
launchId: row.launch_id,
processId: row.process_id,
installationId: row.installation_id,
ownerToken: row.owner_token,
state: row.state,
...(row.process_identity_json === null
? {}
: {
processIdentity: parseSerializedLinuxRuntimeProcessIdentity(
row.process_identity_json
)
}),
createdAt: row.created_at,
updatedAt: row.updated_at
}
}