158 lines
6.6 KiB
Swift
158 lines
6.6 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import Observation
|
|
import TermindDomain
|
|
|
|
public nonisolated enum TerminalRuntimeError: Error, Sendable {
|
|
case capacityUnavailable, unsupported, invalidResponse, staleContext
|
|
}
|
|
|
|
/// Implementations may only use the terminal's existing authenticated transport.
|
|
public nonisolated protocol TerminalRuntimeAdapter: Sendable {
|
|
func inspect(terminal: String) async throws -> TerminalRuntimeUpdate
|
|
}
|
|
|
|
@MainActor @Observable
|
|
public final class TerminalContextCoordinator {
|
|
public struct Ticket: Equatable, Sendable {
|
|
public let generation: UUID
|
|
public let revision: UInt64
|
|
public let context: TerminalContextIdentity
|
|
}
|
|
|
|
public private(set) var generation = UUID()
|
|
public private(set) var revision: UInt64 = 0
|
|
public private(set) var status: TerminalRuntimeUpdate.Status = .unknown
|
|
public private(set) var active: TerminalShellContext?
|
|
public private(set) var contexts: [TerminalContextIdentity: TerminalShellContext] = [:]
|
|
public private(set) var clientIdentity: String?
|
|
public private(set) var backend: SessionPersistenceBackendID?
|
|
public private(set) var issue: TerminalRuntimeIssue?
|
|
private var sequences: [TerminalContextIdentity: UInt64] = [:]
|
|
private var running: Set<TerminalContextIdentity> = []
|
|
private var inspectionID: UUID?
|
|
|
|
public init() {}
|
|
|
|
public var directory: String? {
|
|
// The shell's last reported cwd remains useful while a command owns the
|
|
// foreground. Capability tickets, not path display, gate live operations.
|
|
active?.directory
|
|
}
|
|
|
|
public func reset() {
|
|
generation = UUID()
|
|
revision &+= 1
|
|
status = .unknown
|
|
active = nil
|
|
clientIdentity = nil
|
|
backend = nil
|
|
issue = nil
|
|
contexts.removeAll()
|
|
sequences.removeAll()
|
|
running.removeAll()
|
|
inspectionID = nil
|
|
}
|
|
|
|
public func noteInput() { revision &+= 1 }
|
|
|
|
public func ticket(requiring capability: TerminalContextCapabilities) -> Ticket? {
|
|
guard let active, active.promptReady, active.capabilities.contains(capability) else { return nil }
|
|
return Ticket(generation: generation, revision: revision, context: active.identity)
|
|
}
|
|
|
|
public func isCurrent(_ ticket: Ticket, requiring capability: TerminalContextCapabilities) -> Bool {
|
|
self.ticket(requiring: capability) == ticket
|
|
}
|
|
|
|
public func refresh(using adapter: any TerminalRuntimeAdapter, terminal: String) async throws {
|
|
let id = UUID()
|
|
inspectionID = id
|
|
let generation = generation
|
|
let revision = revision
|
|
let update = try await adapter.inspect(terminal: terminal)
|
|
try Task.checkCancellation()
|
|
guard inspectionID == id, self.generation == generation, self.revision == revision else {
|
|
throw TerminalRuntimeError.staleContext
|
|
}
|
|
receive(update, generation: generation)
|
|
}
|
|
|
|
/// Returns only new, contiguous events. A snapshot never invents a command start.
|
|
@discardableResult
|
|
public func receive(_ update: TerminalRuntimeUpdate, generation expected: UUID) -> [TerminalContextEvent] {
|
|
guard expected == generation else { return [] }
|
|
if let active = update.active,
|
|
let snapshot = update.contexts.first(where: { $0.identity == active }),
|
|
let last = sequences[active], snapshot.sequence < last
|
|
{
|
|
return []
|
|
}
|
|
var accepted: [TerminalContextEvent] = []
|
|
var interrupted: Set<TerminalContextIdentity> = []
|
|
if update.status == .multiplexer {
|
|
for context in update.contexts where sequences[context.identity] == nil {
|
|
sequences[context.identity] = context.sequence
|
|
}
|
|
}
|
|
for event in update.events {
|
|
guard let source = update.contexts.first(where: { $0.identity == event.context }),
|
|
event.sequence <= source.sequence
|
|
else { continue }
|
|
let last = sequences[event.context]
|
|
guard last == nil || event.sequence > last! else { continue }
|
|
let contiguous = last.map { $0 < UInt64.max && event.sequence == $0 + 1 } ?? (event.sequence == 1)
|
|
if !contiguous {
|
|
running.remove(event.context)
|
|
interrupted.insert(event.context)
|
|
}
|
|
sequences[event.context] = event.sequence
|
|
switch event.payload {
|
|
case .commandStarted:
|
|
guard source.capabilities.contains(.commandLifecycle) else { continue }
|
|
running.insert(event.context)
|
|
accepted.append(event)
|
|
case .commandFinished:
|
|
guard source.capabilities.contains(.commandLifecycle) else { continue }
|
|
if running.remove(event.context) != nil, contiguous { accepted.append(event) }
|
|
default:
|
|
accepted.append(event)
|
|
}
|
|
}
|
|
let next = Dictionary(update.contexts.map { ($0.identity, $0) }, uniquingKeysWith: { _, new in new })
|
|
for context in update.contexts {
|
|
// Establish a high-water mark when joining an already-running shell.
|
|
if sequences[context.identity] == nil { sequences[context.identity] = context.sequence }
|
|
}
|
|
let nextActive =
|
|
update.status == .ordinary || update.status == .multiplexer ? update.active.flatMap { next[$0] } : nil
|
|
let activeInteractionChanged = accepted.contains { event in
|
|
guard event.context == nextActive?.identity || event.context == active?.identity else { return false }
|
|
if case .draft = event.payload { return false }
|
|
return true
|
|
}
|
|
if status != update.status || clientIdentity != update.clientIdentity
|
|
|| backend != update.backend || issue != update.issue
|
|
|| active?.identity != nextActive?.identity || active?.promptReady != nextActive?.promptReady
|
|
|| active?.directory != nextActive?.directory || active?.capabilities != nextActive?.capabilities
|
|
|| active?.directorySource != nextActive?.directorySource
|
|
|| activeInteractionChanged
|
|
|| nextActive.map({ interrupted.contains($0.identity) }) == true
|
|
{
|
|
revision &+= 1
|
|
}
|
|
status = update.status
|
|
clientIdentity = update.clientIdentity
|
|
backend = update.backend
|
|
issue = update.issue
|
|
active = nextActive
|
|
contexts = next
|
|
if update.status == .multiplexer {
|
|
running = running.filter { $0.backend == "shell" || next[$0] != nil }
|
|
}
|
|
return accepted
|
|
}
|
|
}
|