Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages. Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4. Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
915 lines
26 KiB
TypeScript
915 lines
26 KiB
TypeScript
import type { DatabaseSync } from 'node:sqlite'
|
|
import { isDeepStrictEqual } from 'node:util'
|
|
import {
|
|
runtimeSessionBindingSchema,
|
|
type RuntimeSessionBinding
|
|
} from '../../shared/remote-agent-contracts'
|
|
import { openPrivateSqliteDatabase } from './private-sqlite-database'
|
|
|
|
export type RuntimeSessionBindingListOptions = {
|
|
limit?: number
|
|
afterBindingId?: string
|
|
}
|
|
|
|
export type RuntimeSessionBindingStoreOptions = {
|
|
maximumRows?: number
|
|
}
|
|
|
|
export type RuntimeSessionTransportIdentity = Pick<
|
|
RuntimeSessionBinding,
|
|
'controllerGeneration' | 'daemonBootIdAtOpen' | 'channelEpoch'
|
|
>
|
|
|
|
const defaultMaximumRows = 10_000
|
|
const maximumPageSize = 1_000
|
|
const maximumPruneBatch = 1_000
|
|
|
|
function boundedPositiveInteger(
|
|
value: number,
|
|
maximum: number,
|
|
name: string
|
|
): number {
|
|
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
throw new RangeError(`${name} must be between 1 and ${maximum}`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function resolveMaximumRows(options?: RuntimeSessionBindingStoreOptions): number {
|
|
return boundedPositiveInteger(
|
|
options?.maximumRows ?? defaultMaximumRows,
|
|
1_000_000,
|
|
'maximumRows'
|
|
)
|
|
}
|
|
|
|
function resolveListOptions(
|
|
options?: RuntimeSessionBindingListOptions
|
|
): Required<RuntimeSessionBindingListOptions> {
|
|
return {
|
|
limit: boundedPositiveInteger(
|
|
options?.limit ?? maximumPageSize,
|
|
maximumPageSize,
|
|
'limit'
|
|
),
|
|
afterBindingId: options?.afterBindingId ?? ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Durable Main-side index for ACP session ownership. Implementations must make
|
|
* each put atomic; callers never mutate an object returned by this interface.
|
|
*/
|
|
export interface RuntimeSessionBindingStore {
|
|
getByConversation(
|
|
conversationId: string
|
|
): Promise<RuntimeSessionBinding | undefined>
|
|
getById(bindingId: string): Promise<RuntimeSessionBinding | undefined>
|
|
/**
|
|
* Atomically claims a Runtime-returned ACP session identity for this live
|
|
* binding. A session identity cannot be shared by live bindings, even when
|
|
* new/load/resume complete concurrently.
|
|
*/
|
|
claimAcpSession(
|
|
bindingId: string,
|
|
acpSessionId: string
|
|
): Promise<RuntimeSessionBinding>
|
|
/**
|
|
* Atomically moves an idle or actively recovering ACP session onto a newly
|
|
* opened transport epoch. Prompt identity and ACP session ownership remain
|
|
* fixed; transport cursors restart at zero for the new epoch.
|
|
*/
|
|
rotateTransport(
|
|
bindingId: string,
|
|
expected: RuntimeSessionTransportIdentity,
|
|
next: RuntimeSessionTransportIdentity
|
|
): Promise<RuntimeSessionBinding>
|
|
put(binding: RuntimeSessionBinding): Promise<void>
|
|
pruneClosed(limit?: number): Promise<number>
|
|
listByController(
|
|
controllerId: string,
|
|
options?: RuntimeSessionBindingListOptions
|
|
): Promise<RuntimeSessionBinding[]>
|
|
}
|
|
|
|
export class RuntimeSessionClaimConflictError extends Error {
|
|
readonly code = 'runtime-session-claim-conflict'
|
|
|
|
constructor(readonly acpSessionId: string) {
|
|
super('Runtime ACP session identity is already owned by another live binding')
|
|
this.name = 'RuntimeSessionClaimConflictError'
|
|
}
|
|
}
|
|
|
|
export class RuntimeSessionBindingConflictError extends Error {
|
|
readonly code = 'runtime-session-binding-conflict'
|
|
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'RuntimeSessionBindingConflictError'
|
|
}
|
|
}
|
|
|
|
export class RuntimeSessionBindingCorruptionError extends Error {
|
|
readonly code = 'runtime-session-binding-corruption'
|
|
|
|
constructor(readonly bindingId: string, options?: ErrorOptions) {
|
|
super(`Persisted Runtime binding ${bindingId} is malformed`, options)
|
|
this.name = 'RuntimeSessionBindingCorruptionError'
|
|
}
|
|
}
|
|
|
|
const immutableBindingKeys = [
|
|
'bindingId',
|
|
'controllerId',
|
|
'controllerGeneration',
|
|
'conversationId',
|
|
'hostId',
|
|
'hostRevision',
|
|
'hostKeyGeneration',
|
|
'workspaceIdentity',
|
|
'agentInstallationId',
|
|
'daemonBootIdAtOpen',
|
|
'runtimeId',
|
|
'runtimeBundleDigest',
|
|
'runtimeAdapterDigest',
|
|
'modelBridgeVersion',
|
|
'acpCapabilitiesDigest',
|
|
'channelEpoch'
|
|
] as const
|
|
|
|
const monotonicCursorKeys = [
|
|
'lastOutboundJournaledSequence',
|
|
'lastOutboundDeliveredSequence',
|
|
'lastInboundJournaledSequence',
|
|
'lastMainAckSequence'
|
|
] as const
|
|
|
|
type RuntimeSessionBindingState = RuntimeSessionBinding['state']
|
|
|
|
const allowedStateTransitions: Readonly<
|
|
Record<
|
|
RuntimeSessionBindingState,
|
|
ReadonlySet<RuntimeSessionBindingState>
|
|
>
|
|
> = {
|
|
opening: new Set([
|
|
'opening',
|
|
'ready',
|
|
'prompt-running',
|
|
'interrupted',
|
|
'closed'
|
|
]),
|
|
ready: new Set(['ready', 'prompt-running', 'interrupted', 'closed']),
|
|
'prompt-running': new Set([
|
|
'prompt-running',
|
|
'ready',
|
|
'outcome-unknown',
|
|
'interrupted',
|
|
'closed'
|
|
]),
|
|
reconciling: new Set([
|
|
'reconciling',
|
|
'ready',
|
|
'outcome-unknown',
|
|
'interrupted',
|
|
'closed'
|
|
]),
|
|
'outcome-unknown': new Set([
|
|
'outcome-unknown',
|
|
'reconciling',
|
|
'ready',
|
|
'closed'
|
|
]),
|
|
interrupted: new Set(['interrupted', 'closed']),
|
|
closed: new Set(['closed'])
|
|
}
|
|
|
|
function assertBindingUpdateAllowed(
|
|
previous: RuntimeSessionBinding,
|
|
next: RuntimeSessionBinding
|
|
): void {
|
|
for (const key of immutableBindingKeys) {
|
|
if (previous[key] !== next[key]) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
`Runtime binding ${key} is immutable`
|
|
)
|
|
}
|
|
}
|
|
if (
|
|
!isDeepStrictEqual(
|
|
previous.modelBridgePolicy,
|
|
next.modelBridgePolicy
|
|
)
|
|
) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Runtime binding model bridge policy is immutable'
|
|
)
|
|
}
|
|
if (previous.acpSessionId !== next.acpSessionId) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'ACP session identity must be changed through atomic claim'
|
|
)
|
|
}
|
|
for (const key of monotonicCursorKeys) {
|
|
if (BigInt(next[key]) < BigInt(previous[key])) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
`Runtime binding ${key} cannot regress`
|
|
)
|
|
}
|
|
}
|
|
if (
|
|
previous.activePromptOperationId !== undefined &&
|
|
next.activePromptOperationId !== undefined &&
|
|
previous.activePromptOperationId !==
|
|
next.activePromptOperationId
|
|
) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Active prompt operation identity cannot change before terminalization'
|
|
)
|
|
}
|
|
const startingPrompt =
|
|
previous.activePromptOperationId === undefined &&
|
|
next.activePromptOperationId !== undefined
|
|
const expectedPromptSequence = startingPrompt
|
|
? previous.state === 'opening'
|
|
? previous.promptSequence
|
|
: previous.promptSequence + 1
|
|
: previous.promptSequence
|
|
if (
|
|
!Number.isSafeInteger(expectedPromptSequence) ||
|
|
next.promptSequence !== expectedPromptSequence
|
|
) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Runtime binding prompt sequence is not the next allowed value'
|
|
)
|
|
}
|
|
if (!allowedStateTransitions[previous.state].has(next.state)) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
`Runtime binding cannot transition from ${previous.state} to ${next.state}`
|
|
)
|
|
}
|
|
}
|
|
|
|
function rotateBindingTransport(
|
|
current: RuntimeSessionBinding,
|
|
bindingId: string,
|
|
expected: RuntimeSessionTransportIdentity,
|
|
next: RuntimeSessionTransportIdentity
|
|
): RuntimeSessionBinding {
|
|
if (
|
|
current.bindingId !== bindingId ||
|
|
!(
|
|
(
|
|
current.state === 'ready' &&
|
|
current.activePromptOperationId === undefined
|
|
) ||
|
|
(
|
|
current.state === 'prompt-running' &&
|
|
current.activePromptOperationId !== undefined
|
|
)
|
|
)
|
|
) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Only an idle or actively recovering Runtime binding can rotate transport'
|
|
)
|
|
}
|
|
if (
|
|
current.controllerGeneration !== expected.controllerGeneration ||
|
|
current.daemonBootIdAtOpen !== expected.daemonBootIdAtOpen ||
|
|
current.channelEpoch !== expected.channelEpoch
|
|
) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Runtime binding transport changed before rotation'
|
|
)
|
|
}
|
|
if (
|
|
expected.controllerGeneration === next.controllerGeneration &&
|
|
expected.daemonBootIdAtOpen === next.daemonBootIdAtOpen &&
|
|
expected.channelEpoch === next.channelEpoch
|
|
) {
|
|
return structuredClone(current)
|
|
}
|
|
return runtimeSessionBindingSchema.parse({
|
|
...current,
|
|
...next,
|
|
lastOutboundJournaledSequence: '0',
|
|
lastOutboundDeliveredSequence: '0',
|
|
lastInboundJournaledSequence: '0',
|
|
lastMainAckSequence: '0'
|
|
})
|
|
}
|
|
|
|
type PersistedBindingRow = {
|
|
binding_id: string
|
|
controller_id: string
|
|
conversation_id: string
|
|
acp_session_id: string
|
|
state: string
|
|
binding_json: string
|
|
}
|
|
|
|
const liveBindingPredicate = "state <> 'closed'"
|
|
|
|
/**
|
|
* Durable Runtime binding index. The duplicated, constrained identity columns
|
|
* are intentional: they let SQLite enforce live ownership while the complete
|
|
* strict contract remains the canonical JSON payload.
|
|
*/
|
|
export class SqliteRuntimeSessionBindingStore
|
|
implements RuntimeSessionBindingStore
|
|
{
|
|
private database?: DatabaseSync
|
|
private readonly maximumRows: number
|
|
|
|
constructor(
|
|
databasePath: string,
|
|
options?: RuntimeSessionBindingStoreOptions
|
|
) {
|
|
this.maximumRows = resolveMaximumRows(options)
|
|
const database = openPrivateSqliteDatabase(databasePath)
|
|
try {
|
|
database.exec('PRAGMA journal_mode = WAL')
|
|
database.exec('PRAGMA synchronous = FULL')
|
|
database.exec('PRAGMA foreign_keys = ON')
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS runtime_session_bindings (
|
|
binding_id TEXT PRIMARY KEY
|
|
CHECK(length(binding_id) BETWEEN 1 AND 128),
|
|
controller_id TEXT NOT NULL
|
|
CHECK(length(controller_id) BETWEEN 1 AND 128),
|
|
conversation_id TEXT NOT NULL
|
|
CHECK(length(conversation_id) BETWEEN 1 AND 128),
|
|
acp_session_id TEXT NOT NULL
|
|
CHECK(length(acp_session_id) BETWEEN 1 AND 128),
|
|
state TEXT NOT NULL CHECK(state IN (
|
|
'opening',
|
|
'ready',
|
|
'prompt-running',
|
|
'reconciling',
|
|
'closed',
|
|
'interrupted',
|
|
'outcome-unknown'
|
|
)),
|
|
binding_json TEXT NOT NULL
|
|
CHECK(length(binding_json) BETWEEN 2 AND 32768)
|
|
) STRICT;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
runtime_session_bindings_live_conversation
|
|
ON runtime_session_bindings(conversation_id)
|
|
WHERE ${liveBindingPredicate};
|
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
runtime_session_bindings_live_acp_session
|
|
ON runtime_session_bindings(acp_session_id)
|
|
WHERE ${liveBindingPredicate};
|
|
CREATE INDEX IF NOT EXISTS
|
|
runtime_session_bindings_controller
|
|
ON runtime_session_bindings(controller_id, binding_id);
|
|
`)
|
|
this.database = database
|
|
this.enforceExistingCapacity(database)
|
|
} catch (error) {
|
|
this.database = undefined
|
|
database.close()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async getByConversation(
|
|
conversationId: string
|
|
): Promise<RuntimeSessionBinding | undefined> {
|
|
const rows = this.requireDatabase()
|
|
.prepare(`
|
|
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
|
state, binding_json
|
|
FROM runtime_session_bindings
|
|
WHERE conversation_id = ? AND ${liveBindingPredicate}
|
|
`)
|
|
.all(conversationId) as PersistedBindingRow[]
|
|
if (rows.length > 1) {
|
|
throw new RuntimeSessionBindingCorruptionError(rows[0]!.binding_id)
|
|
}
|
|
return rows[0] ? this.decode(rows[0]) : undefined
|
|
}
|
|
|
|
async getById(
|
|
bindingId: string
|
|
): Promise<RuntimeSessionBinding | undefined> {
|
|
const row = this.selectById(bindingId)
|
|
return row ? this.decode(row) : undefined
|
|
}
|
|
|
|
async put(binding: RuntimeSessionBinding): Promise<void> {
|
|
const parsed = runtimeSessionBindingSchema.parse(structuredClone(binding))
|
|
const database = this.requireDatabase()
|
|
this.transaction(database, () => {
|
|
const previousRow = this.selectById(parsed.bindingId)
|
|
if (previousRow) {
|
|
const previous = this.decode(previousRow)
|
|
assertBindingUpdateAllowed(previous, parsed)
|
|
} else {
|
|
this.makeCapacityForInsert(database)
|
|
}
|
|
|
|
if (parsed.state !== 'closed') {
|
|
this.assertConversationAvailable(
|
|
parsed.conversationId,
|
|
parsed.bindingId
|
|
)
|
|
this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId)
|
|
}
|
|
|
|
database
|
|
.prepare(`
|
|
INSERT INTO runtime_session_bindings (
|
|
binding_id, controller_id, conversation_id, acp_session_id,
|
|
state, binding_json
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(binding_id) DO UPDATE SET
|
|
controller_id = excluded.controller_id,
|
|
conversation_id = excluded.conversation_id,
|
|
acp_session_id = excluded.acp_session_id,
|
|
state = excluded.state,
|
|
binding_json = excluded.binding_json
|
|
`)
|
|
.run(
|
|
parsed.bindingId,
|
|
parsed.controllerId,
|
|
parsed.conversationId,
|
|
parsed.acpSessionId,
|
|
parsed.state,
|
|
JSON.stringify(parsed)
|
|
)
|
|
})
|
|
}
|
|
|
|
async claimAcpSession(
|
|
bindingId: string,
|
|
acpSessionId: string
|
|
): Promise<RuntimeSessionBinding> {
|
|
const database = this.requireDatabase()
|
|
let result: RuntimeSessionBinding | undefined
|
|
this.transaction(database, () => {
|
|
const row = this.selectById(bindingId)
|
|
if (!row) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Cannot claim an ACP session for an unknown binding'
|
|
)
|
|
}
|
|
const current = this.decode(row)
|
|
if (current.state === 'closed') {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Cannot claim an ACP session for a closed binding'
|
|
)
|
|
}
|
|
const claimed = runtimeSessionBindingSchema.parse({
|
|
...current,
|
|
acpSessionId
|
|
})
|
|
this.assertSessionAvailable(claimed.acpSessionId, bindingId)
|
|
database
|
|
.prepare(`
|
|
UPDATE runtime_session_bindings
|
|
SET acp_session_id = ?, binding_json = ?
|
|
WHERE binding_id = ?
|
|
`)
|
|
.run(
|
|
claimed.acpSessionId,
|
|
JSON.stringify(claimed),
|
|
claimed.bindingId
|
|
)
|
|
result = structuredClone(claimed)
|
|
})
|
|
if (!result) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'ACP session claim did not produce a binding'
|
|
)
|
|
}
|
|
return structuredClone(result)
|
|
}
|
|
|
|
async rotateTransport(
|
|
bindingId: string,
|
|
expected: RuntimeSessionTransportIdentity,
|
|
next: RuntimeSessionTransportIdentity
|
|
): Promise<RuntimeSessionBinding> {
|
|
const database = this.requireDatabase()
|
|
let result: RuntimeSessionBinding | undefined
|
|
this.transaction(database, () => {
|
|
const row = this.selectById(bindingId)
|
|
if (!row) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Cannot rotate transport for an unknown Runtime binding'
|
|
)
|
|
}
|
|
const rotated = rotateBindingTransport(
|
|
this.decode(row),
|
|
bindingId,
|
|
expected,
|
|
next
|
|
)
|
|
database
|
|
.prepare(`
|
|
UPDATE runtime_session_bindings
|
|
SET binding_json = ?
|
|
WHERE binding_id = ?
|
|
`)
|
|
.run(JSON.stringify(rotated), rotated.bindingId)
|
|
result = structuredClone(rotated)
|
|
})
|
|
if (!result) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Runtime binding transport rotation did not produce a binding'
|
|
)
|
|
}
|
|
return structuredClone(result)
|
|
}
|
|
|
|
async listByController(
|
|
controllerId: string,
|
|
options?: RuntimeSessionBindingListOptions
|
|
): Promise<RuntimeSessionBinding[]> {
|
|
const page = resolveListOptions(options)
|
|
const rows = this.requireDatabase()
|
|
.prepare(`
|
|
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
|
state, binding_json
|
|
FROM runtime_session_bindings
|
|
WHERE controller_id = ? AND binding_id > ?
|
|
ORDER BY binding_id
|
|
LIMIT ?
|
|
`)
|
|
.all(
|
|
controllerId,
|
|
page.afterBindingId,
|
|
page.limit
|
|
) as PersistedBindingRow[]
|
|
return rows.map((row) => this.decode(row))
|
|
}
|
|
|
|
async pruneClosed(limit = maximumPruneBatch): Promise<number> {
|
|
const boundedLimit = boundedPositiveInteger(
|
|
limit,
|
|
maximumPruneBatch,
|
|
'limit'
|
|
)
|
|
const database = this.requireDatabase()
|
|
let deleted = 0
|
|
this.transaction(database, () => {
|
|
deleted = this.deleteClosed(database, boundedLimit)
|
|
})
|
|
return deleted
|
|
}
|
|
|
|
close(): void {
|
|
this.database?.close()
|
|
this.database = undefined
|
|
}
|
|
|
|
dispose(): void {
|
|
this.close()
|
|
}
|
|
|
|
private selectById(bindingId: string): PersistedBindingRow | undefined {
|
|
return this.requireDatabase()
|
|
.prepare(`
|
|
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
|
state, binding_json
|
|
FROM runtime_session_bindings
|
|
WHERE binding_id = ?
|
|
`)
|
|
.get(bindingId) as PersistedBindingRow | undefined
|
|
}
|
|
|
|
private decode(row: PersistedBindingRow): RuntimeSessionBinding {
|
|
try {
|
|
const parsed = runtimeSessionBindingSchema.parse(
|
|
JSON.parse(row.binding_json)
|
|
)
|
|
if (
|
|
parsed.bindingId !== row.binding_id ||
|
|
parsed.controllerId !== row.controller_id ||
|
|
parsed.conversationId !== row.conversation_id ||
|
|
parsed.acpSessionId !== row.acp_session_id ||
|
|
parsed.state !== row.state
|
|
) {
|
|
throw new Error('Indexed binding fields do not match payload')
|
|
}
|
|
return structuredClone(parsed)
|
|
} catch (error) {
|
|
throw new RuntimeSessionBindingCorruptionError(row.binding_id, {
|
|
cause: error
|
|
})
|
|
}
|
|
}
|
|
|
|
private assertConversationAvailable(
|
|
conversationId: string,
|
|
bindingId: string
|
|
): void {
|
|
const owner = this.requireDatabase()
|
|
.prepare(`
|
|
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
|
state, binding_json
|
|
FROM runtime_session_bindings
|
|
WHERE conversation_id = ? AND binding_id <> ?
|
|
AND ${liveBindingPredicate}
|
|
`)
|
|
.get(conversationId, bindingId) as PersistedBindingRow | undefined
|
|
if (owner) {
|
|
this.decode(owner)
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Conversation is already owned by another live Runtime binding'
|
|
)
|
|
}
|
|
}
|
|
|
|
private assertSessionAvailable(
|
|
acpSessionId: string,
|
|
bindingId: string
|
|
): void {
|
|
const owner = this.requireDatabase()
|
|
.prepare(`
|
|
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
|
state, binding_json
|
|
FROM runtime_session_bindings
|
|
WHERE acp_session_id = ? AND binding_id <> ?
|
|
AND ${liveBindingPredicate}
|
|
`)
|
|
.get(acpSessionId, bindingId) as PersistedBindingRow | undefined
|
|
if (owner) {
|
|
this.decode(owner)
|
|
throw new RuntimeSessionClaimConflictError(acpSessionId)
|
|
}
|
|
}
|
|
|
|
private makeCapacityForInsert(database: DatabaseSync): void {
|
|
const row = database
|
|
.prepare(
|
|
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
|
)
|
|
.get() as { count: number }
|
|
const required = row.count - this.maximumRows + 1
|
|
if (required <= 0) {
|
|
return
|
|
}
|
|
this.deleteClosed(database, required)
|
|
const remaining = database
|
|
.prepare(
|
|
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
|
)
|
|
.get() as { count: number }
|
|
if (remaining.count >= this.maximumRows) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Runtime binding store capacity is exhausted by authoritative bindings'
|
|
)
|
|
}
|
|
}
|
|
|
|
private enforceExistingCapacity(database: DatabaseSync): void {
|
|
const row = database
|
|
.prepare(
|
|
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
|
)
|
|
.get() as { count: number }
|
|
const excess = row.count - this.maximumRows
|
|
if (excess <= 0) {
|
|
return
|
|
}
|
|
this.transaction(database, () => {
|
|
this.deleteClosed(database, excess)
|
|
const remaining = database
|
|
.prepare(
|
|
'SELECT COUNT(*) AS count FROM runtime_session_bindings'
|
|
)
|
|
.get() as { count: number }
|
|
if (remaining.count > this.maximumRows) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Runtime binding store exceeds capacity with authoritative bindings'
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
private deleteClosed(database: DatabaseSync, limit: number): number {
|
|
const rows = database
|
|
.prepare(`
|
|
SELECT binding_id, controller_id, conversation_id, acp_session_id,
|
|
state, binding_json
|
|
FROM runtime_session_bindings
|
|
WHERE state = 'closed'
|
|
ORDER BY binding_id
|
|
LIMIT ?
|
|
`)
|
|
.all(limit) as PersistedBindingRow[]
|
|
for (const row of rows) {
|
|
const binding = this.decode(row)
|
|
if (binding.state !== 'closed') {
|
|
throw new RuntimeSessionBindingCorruptionError(binding.bindingId)
|
|
}
|
|
}
|
|
const remove = database.prepare(
|
|
'DELETE FROM runtime_session_bindings WHERE binding_id = ?'
|
|
)
|
|
let deleted = 0
|
|
for (const row of rows) {
|
|
deleted += Number(remove.run(row.binding_id).changes)
|
|
}
|
|
return deleted
|
|
}
|
|
|
|
private transaction(database: DatabaseSync, operation: () => void): void {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
operation()
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
try {
|
|
database.exec('ROLLBACK')
|
|
} catch {
|
|
// Preserve the original domain, validation, or SQLite failure.
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private requireDatabase(): DatabaseSync {
|
|
if (!this.database) {
|
|
throw new Error('Runtime session binding store is closed')
|
|
}
|
|
return this.database
|
|
}
|
|
}
|
|
|
|
/** A strict store useful for tests and non-persistent composition roots. */
|
|
export class MemoryRuntimeSessionBindingStore
|
|
implements RuntimeSessionBindingStore
|
|
{
|
|
private readonly bindings = new Map<string, RuntimeSessionBinding>()
|
|
private readonly maximumRows: number
|
|
|
|
constructor(options?: RuntimeSessionBindingStoreOptions) {
|
|
this.maximumRows = resolveMaximumRows(options)
|
|
}
|
|
|
|
async getByConversation(
|
|
conversationId: string
|
|
): Promise<RuntimeSessionBinding | undefined> {
|
|
const binding = [...this.bindings.values()].find(
|
|
(candidate) =>
|
|
candidate.conversationId === conversationId &&
|
|
candidate.state !== 'closed'
|
|
)
|
|
return binding ? structuredClone(binding) : undefined
|
|
}
|
|
|
|
async getById(
|
|
bindingId: string
|
|
): Promise<RuntimeSessionBinding | undefined> {
|
|
const binding = this.bindings.get(bindingId)
|
|
return binding ? structuredClone(binding) : undefined
|
|
}
|
|
|
|
async put(binding: RuntimeSessionBinding): Promise<void> {
|
|
const parsed = runtimeSessionBindingSchema.parse(binding)
|
|
const previous = this.bindings.get(parsed.bindingId)
|
|
if (previous) {
|
|
assertBindingUpdateAllowed(previous, parsed)
|
|
} else {
|
|
this.makeCapacityForInsert()
|
|
}
|
|
if (parsed.state !== 'closed') {
|
|
const conversationOwner = [...this.bindings.values()].find(
|
|
(candidate) =>
|
|
candidate.bindingId !== parsed.bindingId &&
|
|
candidate.state !== 'closed' &&
|
|
candidate.conversationId === parsed.conversationId
|
|
)
|
|
if (conversationOwner) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Conversation is already owned by another live Runtime binding'
|
|
)
|
|
}
|
|
this.assertSessionAvailable(parsed.acpSessionId, parsed.bindingId)
|
|
}
|
|
this.bindings.set(parsed.bindingId, structuredClone(parsed))
|
|
}
|
|
|
|
async claimAcpSession(
|
|
bindingId: string,
|
|
acpSessionId: string
|
|
): Promise<RuntimeSessionBinding> {
|
|
const current = this.bindings.get(bindingId)
|
|
if (!current) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Cannot claim an ACP session for an unknown binding'
|
|
)
|
|
}
|
|
if (current.state === 'closed') {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Cannot claim an ACP session for a closed binding'
|
|
)
|
|
}
|
|
this.assertSessionAvailable(acpSessionId, bindingId)
|
|
const claimed = runtimeSessionBindingSchema.parse({
|
|
...current,
|
|
acpSessionId
|
|
})
|
|
this.bindings.set(bindingId, structuredClone(claimed))
|
|
return structuredClone(claimed)
|
|
}
|
|
|
|
async rotateTransport(
|
|
bindingId: string,
|
|
expected: RuntimeSessionTransportIdentity,
|
|
next: RuntimeSessionTransportIdentity
|
|
): Promise<RuntimeSessionBinding> {
|
|
const current = this.bindings.get(bindingId)
|
|
if (!current) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Cannot rotate transport for an unknown Runtime binding'
|
|
)
|
|
}
|
|
const rotated = rotateBindingTransport(
|
|
current,
|
|
bindingId,
|
|
expected,
|
|
next
|
|
)
|
|
this.bindings.set(bindingId, structuredClone(rotated))
|
|
return structuredClone(rotated)
|
|
}
|
|
|
|
private assertSessionAvailable(
|
|
acpSessionId: string,
|
|
bindingId: string
|
|
): void {
|
|
const owner = [...this.bindings.values()].find(
|
|
(candidate) =>
|
|
candidate.bindingId !== bindingId &&
|
|
candidate.state !== 'closed' &&
|
|
candidate.acpSessionId === acpSessionId
|
|
)
|
|
if (owner) {
|
|
throw new RuntimeSessionClaimConflictError(acpSessionId)
|
|
}
|
|
}
|
|
|
|
async listByController(
|
|
controllerId: string,
|
|
options?: RuntimeSessionBindingListOptions
|
|
): Promise<RuntimeSessionBinding[]> {
|
|
const page = resolveListOptions(options)
|
|
return [...this.bindings.values()]
|
|
.filter(
|
|
(binding) =>
|
|
binding.controllerId === controllerId &&
|
|
binding.bindingId > page.afterBindingId
|
|
)
|
|
.sort((left, right) =>
|
|
left.bindingId < right.bindingId
|
|
? -1
|
|
: left.bindingId > right.bindingId
|
|
? 1
|
|
: 0
|
|
)
|
|
.slice(0, page.limit)
|
|
.map((binding) => structuredClone(binding))
|
|
}
|
|
|
|
async pruneClosed(limit = maximumPruneBatch): Promise<number> {
|
|
const boundedLimit = boundedPositiveInteger(
|
|
limit,
|
|
maximumPruneBatch,
|
|
'limit'
|
|
)
|
|
return this.deleteClosed(boundedLimit)
|
|
}
|
|
|
|
private makeCapacityForInsert(): void {
|
|
const required = this.bindings.size - this.maximumRows + 1
|
|
if (required <= 0) {
|
|
return
|
|
}
|
|
this.deleteClosed(required)
|
|
if (this.bindings.size >= this.maximumRows) {
|
|
throw new RuntimeSessionBindingConflictError(
|
|
'Runtime binding store capacity is exhausted by authoritative bindings'
|
|
)
|
|
}
|
|
}
|
|
|
|
private deleteClosed(limit: number): number {
|
|
const closedIds = [...this.bindings.values()]
|
|
.filter((binding) => binding.state === 'closed')
|
|
.map((binding) => binding.bindingId)
|
|
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
|
|
.slice(0, limit)
|
|
for (const bindingId of closedIds) {
|
|
this.bindings.delete(bindingId)
|
|
}
|
|
return closedIds.length
|
|
}
|
|
}
|