T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles Verify: independent agent re-ran all acceptance — 6/6 PASS
54 lines
2.1 KiB
Swift
54 lines
2.1 KiB
Swift
import Foundation
|
|
|
|
/// One activity-timeline entry from `GET /live-sessions/:id/events` (frozen
|
|
/// contract, plan §3.1; mirrors src/types.ts:428-433). The server derives
|
|
/// `class` semantically (src/types.ts:423: tool/waiting/done/stuck/user) — the
|
|
/// client never re-derives it from hook names. `class` stays a raw `String` so
|
|
/// the SHAPE decodes even for future/unknown classes; consumers drop unknowns
|
|
/// (use `decodeList`, which does exactly that).
|
|
public struct TimelineEvent: Sendable, Equatable, Decodable {
|
|
/// Server ingest timestamp (ms since epoch).
|
|
public let at: Int
|
|
/// Server-derived semantic class; see `knownClasses`.
|
|
public let `class`: String
|
|
/// Sanitized tool name (server-side: ≤200 chars, control chars stripped).
|
|
public let toolName: String?
|
|
/// Server-derived human phrase ("ran Bash", "edited 3 files").
|
|
public let label: String
|
|
|
|
/// The server's `TimelineClass` union (src/types.ts:423).
|
|
public static let knownClasses: Set<String> = ["tool", "waiting", "done", "stuck", "user"]
|
|
|
|
public init(at: Int, class className: String, toolName: String?, label: String) {
|
|
self.at = at
|
|
self.`class` = className
|
|
self.toolName = toolName
|
|
self.label = label
|
|
}
|
|
|
|
/// True when `class` is one the server currently emits.
|
|
public var hasKnownClass: Bool {
|
|
Self.knownClasses.contains(`class`)
|
|
}
|
|
|
|
/// Decode a `/events` response body. NEVER throws: the server is an
|
|
/// untrusted input source — malformed entries and unknown-class entries are
|
|
/// silently dropped; a non-array body yields `[]`.
|
|
public static func decodeList(from data: Data) -> [TimelineEvent] {
|
|
guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else {
|
|
return []
|
|
}
|
|
return entries.compactMap(\.value).filter(\.hasKnownClass)
|
|
}
|
|
}
|
|
|
|
/// Per-element tolerance shim: a malformed element becomes `nil` instead of
|
|
/// failing the whole array decode.
|
|
private struct LossyEntry: Decodable {
|
|
let value: TimelineEvent?
|
|
|
|
init(from decoder: any Decoder) {
|
|
value = try? TimelineEvent(from: decoder)
|
|
}
|
|
}
|