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 工作和同步未确认输出。
233 lines
7.1 KiB
TypeScript
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
|
|
}
|
|
}
|