feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe

T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
This commit is contained in:
Yaojia Wang
2026-07-04 21:53:41 +02:00
parent 2ab93c9682
commit 95438cdc12
32 changed files with 3772 additions and 4 deletions

View File

@@ -0,0 +1,80 @@
import Foundation
import WireProtocol
/// The server's `TimelineClass` vocabulary (src/types.ts:423, produced by
/// src/session/timeline.ts CLASS_MAP). Kept in lockstep with
/// `TimelineEvent.knownClasses` (WireProtocol) a sync test asserts equality.
/// Internal (not public API): consumers see counts, not class strings.
enum TimelineClassName {
static let tool = "tool"
static let waiting = "waiting"
static let done = "done"
static let stuck = "stuck"
static let user = "user"
}
/// "What happened while I was away" summary (T-iOS-6, plan §3.2 frozen
/// shape). Reduced once after a reconnect from the session's activity timeline
/// (`GET /live-sessions/:id/events`, src/types.ts:428-433) and rendered above
/// the terminal (T-iOS-14).
public struct AwayDigest: Sendable, Equatable {
/// Events with class `tool` at/after `since` (PreToolUse + PostToolUse).
public let toolRuns: Int
/// Events with class `waiting` (approval requests / notifications).
public let waitingCount: Int
/// Any `done` event seen (Stop / SessionEnd).
public let sawDone: Bool
/// Any `stuck` event seen (A5 silent-too-long derivation).
public let sawStuck: Bool
/// The newest events (post-`since`), oldestnewest, truncated to `limit`.
public let recent: [TimelineEvent]
/// The all-zero digest: nothing happened UI skips rendering entirely.
public static let empty = AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: [])
public init(
toolRuns: Int, waitingCount: Int, sawDone: Bool, sawStuck: Bool,
recent: [TimelineEvent]
) {
self.toolRuns = toolRuns
self.waitingCount = waitingCount
self.sawDone = sawDone
self.sawStuck = sawStuck
self.recent = recent
}
/// True when there is nothing to show (the UI's "don't render" signal).
public var isEmpty: Bool { self == .empty }
/// Folds a timeline into a digest. PURE function no I/O, no clock.
///
/// - `events`: `/events` entries (untrusted server input; order not assumed
/// entries are stably re-ordered by `at` before truncation).
/// - `since`: the moment the user left; strictly-earlier events are
/// excluded, an event AT the instant still counts.
/// - `limit`: max `recent` entries kept (newest win); `<= 0` keeps none.
public static func reduce(events: [TimelineEvent], since: Date, limit: Int) -> AwayDigest {
let sinceMs = clampedEpochMs(since)
let away = events
.filter { $0.at >= sinceMs }
.sorted { $0.at < $1.at }
guard !away.isEmpty else { return .empty }
return AwayDigest(
toolRuns: away.filter { $0.class == TimelineClassName.tool }.count,
waitingCount: away.filter { $0.class == TimelineClassName.waiting }.count,
sawDone: away.contains { $0.class == TimelineClassName.done },
sawStuck: away.contains { $0.class == TimelineClassName.stuck },
recent: Array(away.suffix(max(0, limit))))
}
/// `Date` epoch-ms `Int`, clamped: `Int(_: Double)` TRAPS on overflow, and
/// `since` is caller input (e.g. `.distantFuture`) clamp instead of crash.
private static func clampedEpochMs(_ date: Date) -> Int {
let ms = date.timeIntervalSince1970 * 1_000
guard ms < Double(Int.max) else { return .max }
guard ms > Double(Int.min) else { return .min }
return Int(ms)
}
}

View File

@@ -0,0 +1,119 @@
import WireProtocol
/// One held permission gate as the UI should render it (T-iOS-6, plan §3.2
/// frozen shape). Produced by `GateTracker` from `status` frames; `nil` gate
/// (via `SessionEvent.gate(nil)`) means "gate lifted".
///
/// `epoch` is the stale-decision guard (mirrors the web client's
/// `pendingEpochValue`, public/terminal-session.ts:98-102): it increments only
/// on a pending falsetrue rising edge, and every approve/reject carries the
/// epoch it was tapped against a stale epoch is dropped so a slow tap can
/// never approve the NEXT gate ( gate).
public struct GateState: Sendable, Equatable {
/// `plan` three-way affordances; `tool` two-way (public/tabs.ts:334-350).
public let kind: GateKind
/// Server-supplied context (tool name for tool gates); untrusted display text.
public let detail: String?
/// Rising-edge counter; see type doc.
public let epoch: Int
public init(kind: GateKind, detail: String?, epoch: Int) {
self.kind = kind
self.detail = detail
self.epoch = epoch
}
}
extension GateState {
/// One user-facing gate action. Pure affordance DATA display strings live
/// in the App layer; the wire mapping mirrors public/tabs.ts:334-350.
public enum Affordance: Sendable, Equatable {
/// Plan gate: "Approve + Auto" `approve(mode: .acceptEdits)`.
case approveAuto
/// Plan gate: "Approve + Review" `approve(mode: .default)`.
case approveReview
/// Plan gate: "Keep Planning" `reject` (stays in plan mode).
case keepPlanning
/// Tool gate: "Approve" `approve(mode: nil)`.
case approve
/// Tool gate: "Reject" `reject`.
case reject
/// The exact wire message the web client sends for this affordance
/// (public/tabs.ts:345-347: plan three-way only ever sends
/// acceptEdits / default / reject never raw `auto`, plan §3.1 note).
public var clientMessage: ClientMessage {
switch self {
case .approveAuto: return .approve(mode: .acceptEdits)
case .approveReview: return .approve(mode: .default)
case .keepPlanning: return .reject
case .approve: return .approve(mode: nil)
case .reject: return .reject
}
}
}
/// The action set for this gate: plan three-way, tool two-way.
public var affordances: [Affordance] {
switch kind {
case .plan: return [.approveAuto, .approveReview, .keepPlanning]
case .tool: return [.approve, .reject]
}
}
}
/// Pure reducer that folds `status` frames' `(pending, gate, detail)` into the
/// current `GateState?` (T-iOS-6). Mirrors public/terminal-session.ts:306-311:
///
/// - pending falsetrue rising edge epoch +1, new gate held;
/// - sustained pending SAME epoch, kind/detail refresh from the latest frame
/// (the server is the source of truth; reattach re-sync stays correct);
/// - pending truefalse falling edge gate cleared, epoch counter retained;
/// - `gate == nil` while pending treated as a tool gate, because an
/// unrecognized gate value decodes as nil (ServerMessage) and the pending
/// signal must never be lost (web: `gate ?? null` tool buttons).
///
/// PURE state machine: `reduce` never mutates the receiver it returns a new
/// immutable snapshot. No I/O, no clock; the caller (SessionEngine, T-iOS-10)
/// feeds frames in and emits `SessionEvent.gate(current)` on change.
public struct GateTracker: Sendable, Equatable {
public static let initial = GateTracker(current: nil, lastEpoch: 0)
/// The gate currently held server-side, or `nil` when none.
public let current: GateState?
/// Highest epoch ever issued; NOT reset on falling edges so decisions
/// against a resolved gate can never match a later one.
private let lastEpoch: Int
private init(current: GateState?, lastEpoch: Int) {
self.current = current
self.lastEpoch = lastEpoch
}
/// Feeds one `status` frame's gate-relevant fields; returns the next snapshot.
public func reduce(pending: Bool, gate: GateKind?, detail: String?) -> GateTracker {
guard pending else {
// Falling edge (or still idle): gate lifted, epoch counter retained.
return GateTracker(current: nil, lastEpoch: lastEpoch)
}
let kind = gate ?? .tool
if let held = current {
// Sustained pending: same epoch, refresh kind/detail from the frame.
let refreshed = GateState(kind: kind, detail: detail, epoch: held.epoch)
return GateTracker(current: refreshed, lastEpoch: lastEpoch)
}
// Rising edge: mint the next epoch (the ONLY place it increments).
let nextEpoch = lastEpoch + 1
let risen = GateState(kind: kind, detail: detail, epoch: nextEpoch)
return GateTracker(current: risen, lastEpoch: nextEpoch)
}
/// True iff a decision tapped against `epoch` may be sent NOW: a gate must
/// be held and its epoch must match. Stale epoch or no gate drop the
/// decision ( gate).
public func canDecide(epoch: Int) -> Bool {
guard let held = current else { return false }
return held.epoch == epoch
}
}

View File

@@ -0,0 +1,62 @@
import WireProtocol
/// WS keep-alive pacer (T-iOS-5, plan §3.2 / §3.2.1).
///
/// `URLSessionWebSocketTask` has NO automatic ping (plan §1), so without this
/// a half-dead connection "looks connected" forever. `run` sends an explicit
/// ping every `interval` (default `Tunables.pingInterval` = 25 s) on the
/// injected clock.
///
/// Miss policy (`Tunables.pongMissLimit` = 2): one missed pong is tolerated;
/// the moment `pongMissLimit` CONSECUTIVE pings go unanswered the connection
/// is declared dead and `run` returns `.connectionLost` the caller
/// (SessionEngine, T-iOS-10) then feeds `.disconnected` into
/// `ReconnectMachine`. Any answered ping resets the consecutive-miss counter.
///
/// Zero real timers: the clock is injected (`any Clock<Duration>`), so tests
/// drive a `FakeClock` and never wait wall-clock time.
public struct PingScheduler: Sendable {
/// How `run` ended.
public enum Outcome: Sendable, Equatable {
/// `Tunables.pongMissLimit` consecutive pings went unanswered treat
/// the transport as disconnected.
case connectionLost
/// The surrounding task was cancelled (normal teardown, e.g. explicit
/// close or transport swap) NOT a connectivity verdict.
case cancelled
}
/// Ping period. Frozen default: `Tunables.pingInterval` (25 s).
public let interval: Duration
public init(interval: Duration = Tunables.pingInterval) {
self.interval = interval
}
/// Loops until the connection dies or the task is cancelled: sleep one
/// `interval` on `clock`, then await `sendPing`.
///
/// `sendPing` returns `true` iff the pong arrived the transport adapter
/// owns ping/pong resolution (e.g. racing `URLSessionWebSocketTask`'s
/// `pongReceiveHandler` against its own deadline, T-iOS-9); `false` = miss.
public func run(
clock: any Clock<Duration>,
sendPing: @Sendable () async -> Bool
) async -> Outcome {
var consecutiveMisses = 0
while true {
do {
try await clock.sleep(for: interval, tolerance: nil)
} catch {
// Clock.sleep only throws CancellationError (FakeClock mirrors
// ContinuousClock semantics) normal teardown, not a failure.
return .cancelled
}
let isPongReceived = await sendPing()
consecutiveMisses = isPongReceived ? 0 : consecutiveMisses + 1
if consecutiveMisses >= Tunables.pongMissLimit {
return .connectionLost
}
}
}
}

View File

@@ -0,0 +1,70 @@
/// Pure reconnect back-off reducer (T-iOS-5, plan §3.2).
///
/// Mirrors the web client's behaviour in `public/terminal-session.ts`:
/// - line 106: first retry delay is 1 s,
/// - lines 352-361 (`scheduleReconnect`): the CURRENT delay is scheduled,
/// then doubled and capped at 30 s (`Math.min(delay * 2, 30_000)`),
/// - line 248: a successful open resets the delay to 1 s.
/// Ladder: 1s 2s 4s 8s 16s 30s 30s
///
/// PURE state machine: `reduce` never mutates the receiver it returns a new
/// immutable snapshot plus the single side-effect the caller (SessionEngine,
/// T-iOS-10) must perform. No clock, no timers here; timing lives with the
/// caller, which is what makes this fully testable with zero real waits.
public struct ReconnectMachine: Sendable, Equatable {
/// First retry delay after a disconnect
/// (mirrors `public/terminal-session.ts:106` `reconnectDelay = 1000`).
static let initialRetryDelay: Duration = .seconds(1)
/// Back-off ceiling
/// (mirrors `public/terminal-session.ts:356` `Math.min(delay * 2, 30_000)`).
static let maxRetryDelay: Duration = .seconds(30)
/// Doubling factor per failed attempt (same mirror line as the ceiling).
static let backoffMultiplier = 2
public enum Input: Sendable, Equatable {
case connected
case disconnected
case retryTimerFired
case foregrounded
case userRetry
}
public enum Effect: Sendable, Equatable {
case connectNow
case scheduleRetry(after: Duration)
case none
}
public static let initial = ReconnectMachine(nextRetryDelay: initialRetryDelay)
/// Delay the NEXT `.disconnected` will schedule.
private let nextRetryDelay: Duration
private init(nextRetryDelay: Duration) {
self.nextRetryDelay = nextRetryDelay
}
/// Feeds one input and returns `(next snapshot, effect to perform)`.
///
/// - `.connected`: back-off resets to the initial 1 s ladder rung; no effect.
/// - `.disconnected`: schedule a retry after the current delay; the next
/// snapshot carries the doubled (capped) delay.
/// - `.retryTimerFired` / `.foregrounded` / `.userRetry`: connect NOW.
/// Foreground/user-initiated attempts deliberately do NOT reset the
/// ladder only a successful `.connected` does, so mashing retry can
/// never turn back-off into hammering.
public func reduce(_ input: Input) -> (ReconnectMachine, Effect) {
switch input {
case .connected:
return (.initial, .none)
case .disconnected:
let doubled = nextRetryDelay * Self.backoffMultiplier
let next = ReconnectMachine(nextRetryDelay: min(doubled, Self.maxRetryDelay))
return (next, .scheduleRetry(after: nextRetryDelay))
case .retryTimerFired, .foregrounded, .userRetry:
return (self, .connectNow)
}
}
}