mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
* docs: design SSH relay PTY backpressure * fix(ssh): bound relay frame decoding * fix(relay): bound PTY output publication * fix(ssh): bound PTY model admission * fix(ssh): settle closed model admissions * feat(ssh): negotiate bounded PTY consumer sessions * fix(ssh): fence exit on renderer settlement * feat(ssh): track PTY source credit end to end * fix(ssh): recover bounded PTY output across reconnect * feat(ssh): complete relay PTY output backpressure * fix(ssh): close final PTY source credit races * docs(ssh): record final backpressure validation * feat(ssh): complete relay PTY source-credit lifecycle * test(ssh): complete provider notification fixture * fix(ssh): preserve terminal source credit across rotation * fix(ssh): fail closed on recovery cancellation * fix(ssh): prioritize mux control writes after drain * fix(ssh): retire canceled relay restore deliveries * fix(ssh): order exit cancellation cleanup * fix(ssh): gate provisional source activation * test(ssh): register mux drain-priority coverage * fix(ssh): type stale owner recovery mismatches * fix(ssh): close projection replacement races * fix(relay): contain streaming edge failures * fix(ssh): secure relay endpoint credentials * docs(ssh): reconcile final backpressure lifecycle * fix(ssh): bound main IPC output lifecycle * fix(ssh): close recovery ownership gaps * docs(ssh): record exact artifact validation * fix(ssh): reject reclaimed snapshot replacements * fix(ssh): fence model admission across reconnect * fix(ssh): contain migration failure per PTY * docs(ssh): record final exact-head validation * test(ssh): align deploy fixtures with credential publication * feat(ssh): add per-target bounded output setting * fix(ssh): close source recovery review gaps * fix(ssh): latch source credit environment override * feat(ssh): make PTY source credit the default * docs(ssh): record always-on relay validation * docs(ssh): bind validation to current main * test(ssh): grant source credit in IPC fixture * test(ssh): grant source credit in fake relay --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
286 lines
9.4 KiB
TypeScript
286 lines
9.4 KiB
TypeScript
import {
|
|
sameTerminalOutputSourceIdentity,
|
|
type TerminalOutputSourceRange
|
|
} from '../../shared/terminal-output-source-range'
|
|
import type {
|
|
RemoteTerminalSourceRangeConsumerHooks,
|
|
RemoteTerminalSourceRangeStreamIdentity
|
|
} from '../runtime/remote-terminal-source-range-consumer'
|
|
import type { PtySourceSpan } from '../../shared/pty-source-credit-contract'
|
|
import type { SshPtySourceConsumerId } from './ssh-pty-source-obligation-contract'
|
|
import type { SshPtySourceObligationCoordinator } from './ssh-pty-source-obligation-coordinator'
|
|
import { SshPtyRemoteSourceRangeReplacements } from './ssh-pty-remote-source-range-replacement'
|
|
|
|
function remoteConsumerId(
|
|
identity: RemoteTerminalSourceRangeStreamIdentity
|
|
): SshPtySourceConsumerId {
|
|
return `remote:${identity.consumerId}`
|
|
}
|
|
|
|
function uniqueSpanIds(ranges: readonly TerminalOutputSourceRange[]): string[] {
|
|
return Array.from(new Set(ranges.map((range) => range.spanId)))
|
|
}
|
|
|
|
type RemoteConsumerState = {
|
|
streamGeneration: string
|
|
spans: Map<
|
|
string,
|
|
Readonly<{
|
|
identity: PtySourceSpan
|
|
modelSequenceEnd: number
|
|
}>
|
|
>
|
|
ackedEndBySpan: Map<string, number>
|
|
}
|
|
|
|
export class SshPtyRemoteSourceRangeConsumers {
|
|
private readonly consumersByPty = new Map<string, Map<string, RemoteConsumerState>>()
|
|
private readonly replacements: SshPtyRemoteSourceRangeReplacements
|
|
|
|
constructor(
|
|
private readonly coordinator: SshPtySourceObligationCoordinator,
|
|
private readonly onProgress: (range: TerminalOutputSourceRange) => void = () => {}
|
|
) {
|
|
this.replacements = new SshPtyRemoteSourceRangeReplacements(coordinator)
|
|
}
|
|
|
|
readonly hooks: RemoteTerminalSourceRangeConsumerHooks = {
|
|
attach: (identity) => this.attach(identity),
|
|
settle: (identity, ranges) => this.settle(identity, ranges),
|
|
reserveReplacement: (identity, requiredSeq, reason) =>
|
|
this.reserveReplacement(identity, requiredSeq, reason),
|
|
commitReplacement: (reservation, publication) =>
|
|
this.commitReplacement(reservation, publication),
|
|
rollbackReplacement: (reservation, reason) => this.rollbackReplacement(reservation, reason),
|
|
cancel: (identity, ranges, reason) => this.cancel(identity, ranges, reason)
|
|
}
|
|
|
|
requiredConsumers(ptyId: string): readonly SshPtySourceConsumerId[] {
|
|
return Object.freeze(
|
|
Array.from(this.consumersByPty.get(ptyId)?.keys() ?? []).map(
|
|
(consumerId) => `remote:${consumerId}` as const
|
|
)
|
|
)
|
|
}
|
|
|
|
trackSpan(
|
|
ptyId: string,
|
|
spanId: string,
|
|
requiredConsumers: readonly SshPtySourceConsumerId[],
|
|
modelSequenceEnd: number
|
|
): void {
|
|
if (!Number.isSafeInteger(modelSequenceEnd) || modelSequenceEnd < 0) {
|
|
throw new Error('ssh_remote_source_range_model_sequence_invalid')
|
|
}
|
|
for (const [consumerId, state] of this.consumersByPty.get(ptyId) ?? []) {
|
|
if (requiredConsumers.includes(`remote:${consumerId}`)) {
|
|
state.spans.set(
|
|
spanId,
|
|
Object.freeze({
|
|
identity: this.coordinator.spanIdentity(spanId),
|
|
modelSequenceEnd
|
|
})
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
closeGeneration(providerGeneration: number, reason: string): void {
|
|
this.replacements.closeGeneration(providerGeneration, reason)
|
|
for (const consumers of this.consumersByPty.values()) {
|
|
for (const state of consumers.values()) {
|
|
for (const [spanId, tracked] of state.spans) {
|
|
if (tracked.identity.providerGeneration === providerGeneration) {
|
|
state.spans.delete(spanId)
|
|
state.ackedEndBySpan.delete(spanId)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private attach(identity: RemoteTerminalSourceRangeStreamIdentity): boolean {
|
|
const consumers =
|
|
this.consumersByPty.get(identity.ptyId) ?? new Map<string, RemoteConsumerState>()
|
|
const current = consumers.get(identity.consumerId)
|
|
if (current && current.streamGeneration !== identity.streamGeneration) {
|
|
return false
|
|
}
|
|
consumers.set(
|
|
identity.consumerId,
|
|
current ?? {
|
|
streamGeneration: identity.streamGeneration,
|
|
spans: new Map(),
|
|
ackedEndBySpan: new Map()
|
|
}
|
|
)
|
|
this.consumersByPty.set(identity.ptyId, consumers)
|
|
return true
|
|
}
|
|
|
|
private settle(
|
|
identity: RemoteTerminalSourceRangeStreamIdentity,
|
|
ranges: readonly TerminalOutputSourceRange[]
|
|
): void {
|
|
if (!this.isCurrent(identity)) {
|
|
return
|
|
}
|
|
const state = this.requireState(identity)
|
|
const consumer = remoteConsumerId(identity)
|
|
const nextEnds = new Map(state.ackedEndBySpan)
|
|
const completed = new Set<string>()
|
|
for (const range of ranges) {
|
|
const tracked = state.spans.get(range.spanId)
|
|
if (!tracked) {
|
|
continue
|
|
}
|
|
const source = tracked.identity
|
|
if (!this.coordinator.hasRetainedSpan(range.spanId)) {
|
|
state.spans.delete(range.spanId)
|
|
nextEnds.delete(range.spanId)
|
|
continue
|
|
}
|
|
const currentEnd = nextEnds.get(range.spanId) ?? source.sourceStartSu
|
|
if (
|
|
!sameTerminalOutputSourceIdentity(source, range) ||
|
|
range.sourceStartSu !== currentEnd ||
|
|
range.sourceEndSu > source.sourceEndSu
|
|
) {
|
|
throw new Error('ssh_remote_source_range_settlement_invalid')
|
|
}
|
|
nextEnds.set(range.spanId, range.sourceEndSu)
|
|
if (range.sourceEndSu === source.sourceEndSu) {
|
|
completed.add(range.spanId)
|
|
}
|
|
}
|
|
state.ackedEndBySpan = nextEnds
|
|
for (const spanId of completed) {
|
|
const tracked = state.spans.get(spanId)
|
|
if (!tracked || !this.coordinator.hasRetainedSpan(spanId)) {
|
|
state.spans.delete(spanId)
|
|
state.ackedEndBySpan.delete(spanId)
|
|
continue
|
|
}
|
|
const source = tracked.identity
|
|
this.coordinator.settle({
|
|
identity: source,
|
|
spanId,
|
|
consumer,
|
|
reason: 'remote-frame-ack'
|
|
})
|
|
state.spans.delete(spanId)
|
|
state.ackedEndBySpan.delete(spanId)
|
|
}
|
|
for (const range of ranges) {
|
|
this.onProgress(range)
|
|
}
|
|
}
|
|
|
|
private reserveReplacement(
|
|
identity: RemoteTerminalSourceRangeStreamIdentity,
|
|
requiredSeq: number,
|
|
reason: string
|
|
): ReturnType<SshPtyRemoteSourceRangeReplacements['reserve']> {
|
|
if (!this.isCurrent(identity)) {
|
|
throw new Error('ssh_remote_source_range_stale_generation')
|
|
}
|
|
const state = this.requireState(identity)
|
|
for (const spanId of state.spans.keys()) {
|
|
if (!this.coordinator.hasRetainedSpan(spanId)) {
|
|
state.spans.delete(spanId)
|
|
state.ackedEndBySpan.delete(spanId)
|
|
}
|
|
}
|
|
const spanIds = Array.from(state.spans)
|
|
.filter(([, tracked]) => tracked.modelSequenceEnd <= requiredSeq)
|
|
.map(([spanId]) => spanId)
|
|
return this.replacements.reserve(identity, spanIds, requiredSeq, reason)
|
|
}
|
|
|
|
private commitReplacement(
|
|
reservation: Parameters<SshPtyRemoteSourceRangeReplacements['commit']>[0],
|
|
publication: Parameters<SshPtyRemoteSourceRangeReplacements['commit']>[1]
|
|
): boolean {
|
|
return this.replacements.commit(
|
|
reservation,
|
|
publication,
|
|
this.isCurrent(reservation.identity),
|
|
(spanIds) => {
|
|
const state = this.requireState(reservation.identity)
|
|
for (const spanId of spanIds) {
|
|
state.spans.delete(spanId)
|
|
state.ackedEndBySpan.delete(spanId)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private rollbackReplacement(
|
|
reservation: Parameters<SshPtyRemoteSourceRangeReplacements['rollback']>[0],
|
|
reason: string
|
|
): boolean {
|
|
return this.replacements.rollback(reservation, reason)
|
|
}
|
|
|
|
private cancel(
|
|
identity: RemoteTerminalSourceRangeStreamIdentity,
|
|
ranges: readonly TerminalOutputSourceRange[],
|
|
reason: string
|
|
): void {
|
|
if (!this.isCurrent(identity)) {
|
|
return
|
|
}
|
|
this.replacements.rollbackIdentity(identity, `${reason}-replacement-aborted`)
|
|
const consumer = remoteConsumerId(identity)
|
|
const state = this.requireState(identity)
|
|
const spanIds = new Set([
|
|
...state.spans.keys(),
|
|
...uniqueSpanIds(ranges).filter((spanId) => state.spans.has(spanId))
|
|
])
|
|
for (const spanId of spanIds) {
|
|
const tracked = state.spans.get(spanId)
|
|
if (!tracked || !this.coordinator.hasRetainedSpan(spanId)) {
|
|
continue
|
|
}
|
|
const source = tracked.identity
|
|
const transition = { identity: source, spanId, consumer, reason }
|
|
if (this.coordinator.beginTransfer(transition, consumer)) {
|
|
this.coordinator.cancelTransfer(transition)
|
|
}
|
|
}
|
|
this.detachIdentity(identity)
|
|
for (const range of ranges) {
|
|
this.onProgress(range)
|
|
}
|
|
}
|
|
|
|
private isCurrent(identity: RemoteTerminalSourceRangeStreamIdentity): boolean {
|
|
return (
|
|
this.consumersByPty.get(identity.ptyId)?.get(identity.consumerId)?.streamGeneration ===
|
|
identity.streamGeneration
|
|
)
|
|
}
|
|
|
|
private requireState(identity: RemoteTerminalSourceRangeStreamIdentity): RemoteConsumerState {
|
|
const state = this.consumersByPty.get(identity.ptyId)?.get(identity.consumerId)
|
|
if (!state || state.streamGeneration !== identity.streamGeneration) {
|
|
throw new Error('ssh_remote_source_range_stale_generation')
|
|
}
|
|
return state
|
|
}
|
|
|
|
private detachIdentity(identity: RemoteTerminalSourceRangeStreamIdentity): void {
|
|
const consumers = this.consumersByPty.get(identity.ptyId)
|
|
if (
|
|
!consumers ||
|
|
consumers.get(identity.consumerId)?.streamGeneration !== identity.streamGeneration
|
|
) {
|
|
return
|
|
}
|
|
consumers.delete(identity.consumerId)
|
|
if (consumers.size === 0) {
|
|
this.consumersByPty.delete(identity.ptyId)
|
|
}
|
|
}
|
|
}
|