Files
luma/Sources/LumaCore/Models/SessionOp.swift
Ole André Vadla Ravnås 9606ec4930 Drop the driver concept; await host-claim ack
The driver seat became fully redundant with the host once session
interaction moved to owner gating: it was always equal to the host and
nothing read it for authorization. Remove it wholesale — the
claim-driver op, driverBySession, localUserIsDriver, Session.driver,
and the REPL "Driving" indicator.

Gate REPL submission on ownership (solo always). Claim hosting through
an acknowledged request and attach only once the server accepts,
instead of optimistically attaching before the claim is even sent.
2026-06-26 15:34:02 +02:00

843 lines
30 KiB
Swift

import Foundation
/// A single pending mutation against a lab's sessions. Mirrors the
/// NotebookOp pattern: clients persist ops to the outbox as they happen,
/// the server deduplicates by `opID`, applies, and broadcasts the
/// authoritative result back. Session IDs are client-generated so a host
/// can attach offline and have the session announced on reconnect.
public enum SessionOp: Sendable {
case add(Add)
case updatePhase(UpdatePhase)
case updateProcessInfo(UpdateProcessInfo)
case updateArming(UpdateArming)
case updateModules(UpdateModules)
case updateModuleAnalysis(UpdateModuleAnalysis)
case updateModuleSymbols(UpdateModuleSymbols)
case updateThreads(UpdateThreads)
case claimHost(ClaimHost)
case addReplCell(AddReplCell)
case addInstrument(AddInstrument)
case updateInstrument(UpdateInstrument)
case removeInstrument(RemoveInstrument)
case addInsight(AddInsight)
case updateInsight(UpdateInsight)
case removeInsight(RemoveInsight)
case upsertTrace(UpsertTrace)
case removeTrace(RemoveTrace)
case traceDataProgressed(TraceDataProgressed)
case remove(Remove)
public var opID: UUID {
switch self {
case .add(let a): return a.opID
case .updatePhase(let u): return u.opID
case .updateProcessInfo(let u): return u.opID
case .updateArming(let u): return u.opID
case .updateModules(let u): return u.opID
case .updateModuleAnalysis(let u): return u.opID
case .updateModuleSymbols(let u): return u.opID
case .updateThreads(let u): return u.opID
case .claimHost(let c): return c.opID
case .addReplCell(let a): return a.opID
case .addInstrument(let a): return a.opID
case .updateInstrument(let u): return u.opID
case .removeInstrument(let r): return r.opID
case .addInsight(let a): return a.opID
case .updateInsight(let u): return u.opID
case .removeInsight(let r): return r.opID
case .upsertTrace(let u): return u.opID
case .removeTrace(let r): return r.opID
case .traceDataProgressed(let t): return t.opID
case .remove(let r): return r.opID
}
}
public var sessionID: UUID {
switch self {
case .add(let a): return a.sessionID
case .updatePhase(let u): return u.sessionID
case .updateProcessInfo(let u): return u.sessionID
case .updateArming(let u): return u.sessionID
case .updateModules(let u): return u.sessionID
case .updateModuleAnalysis(let u): return u.sessionID
case .updateModuleSymbols(let u): return u.sessionID
case .updateThreads(let u): return u.sessionID
case .claimHost(let c): return c.sessionID
case .addReplCell(let a): return a.sessionID
case .addInstrument(let a): return a.sessionID
case .updateInstrument(let u): return u.sessionID
case .removeInstrument(let r): return r.sessionID
case .addInsight(let a): return a.sessionID
case .updateInsight(let u): return u.sessionID
case .removeInsight(let r): return r.sessionID
case .upsertTrace(let u): return u.sessionID
case .removeTrace(let r): return r.sessionID
case .traceDataProgressed(let t): return t.sessionID
case .remove(let r): return r.sessionID
}
}
public var kind: String {
switch self {
case .add: return "add"
case .updatePhase: return "update-phase"
case .updateProcessInfo: return "update-process-info"
case .updateArming: return "update-arming"
case .updateModules: return "update-modules"
case .updateModuleAnalysis: return "update-module-analysis"
case .updateModuleSymbols: return "update-module-symbols"
case .updateThreads: return "update-threads"
case .claimHost: return "claim-host"
case .addReplCell: return "add-repl-cell"
case .addInstrument: return "add-instrument"
case .updateInstrument: return "update-instrument"
case .removeInstrument: return "remove-instrument"
case .addInsight: return "add-insight"
case .updateInsight: return "update-insight"
case .removeInsight: return "remove-insight"
case .upsertTrace: return "upsert-trace"
case .removeTrace: return "remove-trace"
case .traceDataProgressed: return "trace-data-progressed"
case .remove: return "remove"
}
}
public struct Add: Sendable {
public let opID: UUID
public let sessionID: UUID
public let host: CollaborationSession.UserInfo
public let deviceID: String
public let deviceName: String
public let pid: UInt
public let processName: String
public let createdAt: String
public init(
opID: UUID = UUID(),
sessionID: UUID = UUID(),
host: CollaborationSession.UserInfo,
deviceID: String,
deviceName: String,
pid: UInt,
processName: String,
createdAt: String
) {
self.opID = opID
self.sessionID = sessionID
self.host = host
self.deviceID = deviceID
self.deviceName = deviceName
self.pid = pid
self.processName = processName
self.createdAt = createdAt
}
}
public struct UpdatePhase: Sendable {
public let opID: UUID
public let sessionID: UUID
public let phase: CollaborationSession.Session.Phase
public let reason: String?
public let lastSeenAt: String
public init(
opID: UUID = UUID(),
sessionID: UUID,
phase: CollaborationSession.Session.Phase,
reason: String? = nil,
lastSeenAt: String
) {
self.opID = opID
self.sessionID = sessionID
self.phase = phase
self.reason = reason
self.lastSeenAt = lastSeenAt
}
}
public struct UpdateProcessInfo: Sendable {
public let opID: UUID
public let sessionID: UUID
public let info: ProcessSession.ProcessInfo
public init(opID: UUID = UUID(), sessionID: UUID, info: ProcessSession.ProcessInfo) {
self.opID = opID
self.sessionID = sessionID
self.info = info
}
}
public struct UpdateArming: Sendable {
public let opID: UUID
public let sessionID: UUID
public let armingState: ProcessSession.ArmingState
public init(
opID: UUID = UUID(),
sessionID: UUID,
armingState: ProcessSession.ArmingState
) {
self.opID = opID
self.sessionID = sessionID
self.armingState = armingState
}
}
public struct UpdateModuleAnalysis: Sendable {
public let opID: UUID
public let sessionID: UUID
public let analysis: ModuleAnalysis
public init(opID: UUID = UUID(), sessionID: UUID, analysis: ModuleAnalysis) {
self.opID = opID
self.sessionID = sessionID
self.analysis = analysis
}
}
public struct UpdateModuleSymbols: Sendable {
public let opID: UUID
public let sessionID: UUID
public let entries: [Entry]
public struct Entry: Sendable, Hashable {
public let modulePath: String
public let offset: UInt64
public let name: String
public init(modulePath: String, offset: UInt64, name: String) {
self.modulePath = modulePath
self.offset = offset
self.name = name
}
}
public init(opID: UUID = UUID(), sessionID: UUID, entries: [Entry]) {
self.opID = opID
self.sessionID = sessionID
self.entries = entries
}
}
public struct UpdateModules: Sendable {
public let opID: UUID
public let sessionID: UUID
public let delta: ModuleDelta
public init(
opID: UUID = UUID(),
sessionID: UUID,
delta: ModuleDelta
) {
self.opID = opID
self.sessionID = sessionID
self.delta = delta
}
}
public struct UpdateThreads: Sendable {
public let opID: UUID
public let sessionID: UUID
public let delta: ThreadDelta
public init(
opID: UUID = UUID(),
sessionID: UUID,
delta: ThreadDelta
) {
self.opID = opID
self.sessionID = sessionID
self.delta = delta
}
}
public struct ClaimHost: Sendable {
public let opID: UUID
public let sessionID: UUID
public let host: CollaborationSession.UserInfo
public let deviceID: String
public let deviceName: String
public let pid: UInt
public let processName: String
public init(
opID: UUID = UUID(),
sessionID: UUID,
host: CollaborationSession.UserInfo,
deviceID: String,
deviceName: String,
pid: UInt,
processName: String
) {
self.opID = opID
self.sessionID = sessionID
self.host = host
self.deviceID = deviceID
self.deviceName = deviceName
self.pid = pid
self.processName = processName
}
}
public struct AddReplCell: Sendable {
public let opID: UUID
public let sessionID: UUID
public let cell: REPLCell
public init(opID: UUID = UUID(), sessionID: UUID, cell: REPLCell) {
self.opID = opID
self.sessionID = sessionID
self.cell = cell
}
}
public struct AddInstrument: Sendable {
public let opID: UUID
public let sessionID: UUID
public let instance: InstrumentInstance
public let runtimeStatus: InstrumentStatus?
public init(
opID: UUID = UUID(),
sessionID: UUID,
instance: InstrumentInstance,
runtimeStatus: InstrumentStatus? = nil
) {
self.opID = opID
self.sessionID = sessionID
self.instance = instance
self.runtimeStatus = runtimeStatus
}
}
public struct UpdateInstrument: Sendable {
public let opID: UUID
public let sessionID: UUID
public let instance: InstrumentInstance
public let runtimeStatus: InstrumentStatus?
public init(
opID: UUID = UUID(),
sessionID: UUID,
instance: InstrumentInstance,
runtimeStatus: InstrumentStatus? = nil
) {
self.opID = opID
self.sessionID = sessionID
self.instance = instance
self.runtimeStatus = runtimeStatus
}
}
public struct RemoveInstrument: Sendable {
public let opID: UUID
public let sessionID: UUID
public let instanceID: UUID
public init(opID: UUID = UUID(), sessionID: UUID, instanceID: UUID) {
self.opID = opID
self.sessionID = sessionID
self.instanceID = instanceID
}
}
public struct AddInsight: Sendable {
public let opID: UUID
public let sessionID: UUID
public let insight: AddressInsight
public init(opID: UUID = UUID(), sessionID: UUID, insight: AddressInsight) {
self.opID = opID
self.sessionID = sessionID
self.insight = insight
}
}
public struct UpdateInsight: Sendable {
public let opID: UUID
public let sessionID: UUID
public let insight: AddressInsight
public init(opID: UUID = UUID(), sessionID: UUID, insight: AddressInsight) {
self.opID = opID
self.sessionID = sessionID
self.insight = insight
}
}
public struct RemoveInsight: Sendable {
public let opID: UUID
public let sessionID: UUID
public let insightID: UUID
public init(opID: UUID = UUID(), sessionID: UUID, insightID: UUID) {
self.opID = opID
self.sessionID = sessionID
self.insightID = insightID
}
}
public struct UpsertTrace: Sendable {
public let opID: UUID
public let sessionID: UUID
public let trace: ITrace
public init(opID: UUID = UUID(), sessionID: UUID, trace: ITrace) {
self.opID = opID
self.sessionID = sessionID
self.trace = trace
}
}
public struct TraceDataProgressed: Sendable {
public let opID: UUID
public let sessionID: UUID
public let traceID: UUID
public let totalSize: Int
public init(opID: UUID = UUID(), sessionID: UUID, traceID: UUID, totalSize: Int) {
self.opID = opID
self.sessionID = sessionID
self.traceID = traceID
self.totalSize = totalSize
}
}
public struct RemoveTrace: Sendable {
public let opID: UUID
public let sessionID: UUID
public let traceID: UUID
public init(opID: UUID = UUID(), sessionID: UUID, traceID: UUID) {
self.opID = opID
self.sessionID = sessionID
self.traceID = traceID
}
}
public struct Remove: Sendable {
public let opID: UUID
public let sessionID: UUID
public init(opID: UUID = UUID(), sessionID: UUID) {
self.opID = opID
self.sessionID = sessionID
}
}
public func toJSON() -> [String: Any] {
var obj: [String: Any] = [
"op_id": opID.uuidString,
"kind": kind,
]
switch self {
case .add(let a):
obj["host"] = [
"id": a.host.id,
"name": a.host.name,
"avatar": a.host.avatarURL?.absoluteString ?? "",
]
obj["device"] = ["id": a.deviceID, "name": a.deviceName]
obj["process"] = ["pid": a.pid, "name": a.processName]
obj["created_at"] = a.createdAt
case .updatePhase(let u):
obj["phase"] = u.phase.rawValue
obj["last_seen_at"] = u.lastSeenAt
if let reason = u.reason { obj["reason"] = reason }
case .updateProcessInfo(let u):
obj["info"] = [
"platform": u.info.platform,
"arch": u.info.arch,
"pointer_size": u.info.pointerSize,
"identity": u.info.identity,
]
case .updateArming(let u):
obj["arming_state"] = encodeArmingState(u.armingState)
case .updateModules(let u):
obj["added"] = u.delta.added.map { $0.toJSON() }
obj["removed"] = u.delta.removed.map { $0.toJSON() }
case .updateModuleAnalysis(let u):
obj["analysis"] = u.analysis.toWireJSON() ?? [:]
case .updateModuleSymbols(let u):
obj["entries"] = u.entries.map { entry -> [String: Any] in
[
"module_path": entry.modulePath,
"offset": Int64(bitPattern: entry.offset),
"name": entry.name,
]
}
case .updateThreads(let u):
obj["added"] = u.delta.added.map { $0.toJSON() }
obj["removed"] = u.delta.removed.map { Int($0) }
obj["renamed"] = u.delta.renamed.map { rename -> [String: Any] in
var entry: [String: Any] = ["id": Int(rename.id)]
if let name = rename.name { entry["name"] = name }
return entry
}
case .claimHost(let c):
obj["host"] = [
"id": c.host.id,
"name": c.host.name,
"avatar": c.host.avatarURL?.absoluteString ?? "",
]
obj["device"] = ["id": c.deviceID, "name": c.deviceName]
obj["process"] = ["pid": c.pid, "name": c.processName]
case .addReplCell(let a):
if let cellObj = a.cell.toWireJSON() {
obj["cell"] = cellObj
}
case .addInstrument(let a):
if let instObj = Self.wireInstrument(instance: a.instance, runtimeStatus: a.runtimeStatus) {
obj["instance"] = instObj
}
case .updateInstrument(let u):
if let instObj = Self.wireInstrument(instance: u.instance, runtimeStatus: u.runtimeStatus) {
obj["instance"] = instObj
}
case .removeInstrument(let r):
obj["instance_id"] = r.instanceID.uuidString
case .addInsight(let a):
if let insightObj = a.insight.toWireJSON() {
obj["insight"] = insightObj
}
case .updateInsight(let u):
if let insightObj = u.insight.toWireJSON() {
obj["insight"] = insightObj
}
case .removeInsight(let r):
obj["insight_id"] = r.insightID.uuidString
case .upsertTrace(let u):
if let traceObj = u.trace.toWireJSON() {
obj["trace"] = traceObj
}
case .removeTrace(let r):
obj["trace_id"] = r.traceID.uuidString
case .traceDataProgressed(let t):
obj["trace_id"] = t.traceID.uuidString
obj["total_size"] = t.totalSize
case .remove:
break
}
return obj
}
public static func fromJSON(_ obj: [String: Any], sessionID: UUID) -> SessionOp? {
guard let opIDStr = obj["op_id"] as? String,
let opID = UUID(uuidString: opIDStr),
let kind = obj["kind"] as? String
else { return nil }
switch kind {
case "add":
guard let hostObj = obj["host"] as? [String: Any],
let host = CollaborationSession.UserInfo.fromJSON(hostObj),
let deviceObj = obj["device"] as? [String: Any],
let deviceID = deviceObj["id"] as? String,
let deviceName = deviceObj["name"] as? String,
let processObj = obj["process"] as? [String: Any],
let processName = processObj["name"] as? String,
let createdAt = obj["created_at"] as? String
else { return nil }
let pid: UInt
if let v = processObj["pid"] as? Int { pid = UInt(v) }
else if let v = processObj["pid"] as? UInt { pid = v }
else if let v = processObj["pid"] as? NSNumber { pid = v.uintValue }
else { return nil }
return .add(Add(
opID: opID,
sessionID: sessionID,
host: host,
deviceID: deviceID,
deviceName: deviceName,
pid: pid,
processName: processName,
createdAt: createdAt
))
case "update-phase":
guard let phaseRaw = obj["phase"] as? String,
let phase = CollaborationSession.Session.Phase(rawValue: phaseRaw),
let lastSeenAt = obj["last_seen_at"] as? String
else { return nil }
return .updatePhase(UpdatePhase(
opID: opID,
sessionID: sessionID,
phase: phase,
reason: obj["reason"] as? String,
lastSeenAt: lastSeenAt
))
case "update-process-info":
guard let infoObj = obj["info"] as? [String: Any],
let platform = infoObj["platform"] as? String,
let arch = infoObj["arch"] as? String,
let pointerSize = infoObj["pointer_size"] as? Int,
let identity = infoObj["identity"] as? String
else { return nil }
return .updateProcessInfo(UpdateProcessInfo(
opID: opID,
sessionID: sessionID,
info: ProcessSession.ProcessInfo(
platform: platform, arch: arch, pointerSize: pointerSize, identity: identity
)
))
case "update-arming":
guard let stateObj = obj["arming_state"] as? [String: Any],
let armingState = decodeArmingState(stateObj)
else { return nil }
return .updateArming(UpdateArming(
opID: opID,
sessionID: sessionID,
armingState: armingState
))
case "update-module-analysis":
guard let analysisObj = obj["analysis"] as? [String: Any],
let analysis = ModuleAnalysis.fromWireJSON(analysisObj)
else { return nil }
return .updateModuleAnalysis(UpdateModuleAnalysis(
opID: opID,
sessionID: sessionID,
analysis: analysis
))
case "update-module-symbols":
let raw = (obj["entries"] as? [[String: Any]]) ?? []
let entries: [UpdateModuleSymbols.Entry] = raw.compactMap { entry in
guard let modulePath = entry["module_path"] as? String,
let rawOffset = entry["offset"] as? Int64 ?? (entry["offset"] as? Int).map(Int64.init),
let name = entry["name"] as? String
else { return nil }
return UpdateModuleSymbols.Entry(
modulePath: modulePath,
offset: UInt64(bitPattern: rawOffset),
name: name
)
}
return .updateModuleSymbols(UpdateModuleSymbols(
opID: opID,
sessionID: sessionID,
entries: entries
))
case "update-modules":
let addedObjs = (obj["added"] as? [[String: Any]]) ?? []
let removedObjs = (obj["removed"] as? [[String: Any]]) ?? []
let added = addedObjs.compactMap(ProcessModule.fromJSON)
let removed = removedObjs.compactMap(ProcessModule.fromJSON)
return .updateModules(UpdateModules(
opID: opID,
sessionID: sessionID,
delta: ModuleDelta(added: added, removed: removed)
))
case "update-threads":
let addedObjs = (obj["added"] as? [[String: Any]]) ?? []
let removedIDs = (obj["removed"] as? [Int]) ?? []
let renamedObjs = (obj["renamed"] as? [[String: Any]]) ?? []
let added = addedObjs.compactMap(ProcessThread.fromJSON)
let renamed = renamedObjs.compactMap { entry -> ThreadDelta.Rename? in
guard let raw = entry["id"] as? Int else { return nil }
return ThreadDelta.Rename(id: UInt(raw), name: entry["name"] as? String)
}
return .updateThreads(UpdateThreads(
opID: opID,
sessionID: sessionID,
delta: ThreadDelta(
added: added,
removed: removedIDs.map { UInt($0) },
renamed: renamed
)
))
case "claim-host":
guard let hostObj = obj["host"] as? [String: Any],
let host = CollaborationSession.UserInfo.fromJSON(hostObj),
let deviceObj = obj["device"] as? [String: Any],
let deviceID = deviceObj["id"] as? String,
let deviceName = deviceObj["name"] as? String,
let processObj = obj["process"] as? [String: Any],
let processName = processObj["name"] as? String
else { return nil }
let pid: UInt
if let v = processObj["pid"] as? Int { pid = UInt(v) }
else if let v = processObj["pid"] as? UInt { pid = v }
else if let v = processObj["pid"] as? NSNumber { pid = v.uintValue }
else { return nil }
return .claimHost(ClaimHost(
opID: opID,
sessionID: sessionID,
host: host,
deviceID: deviceID,
deviceName: deviceName,
pid: pid,
processName: processName
))
case "add-repl-cell":
guard let cellObj = obj["cell"] as? [String: Any],
let cell = REPLCell.fromWireJSON(cellObj)
else { return nil }
return .addReplCell(AddReplCell(
opID: opID,
sessionID: sessionID,
cell: cell
))
case "add-instrument":
guard let instObj = obj["instance"] as? [String: Any],
let parsed = Self.parseWireInstrument(instObj)
else { return nil }
return .addInstrument(AddInstrument(
opID: opID,
sessionID: sessionID,
instance: parsed.instance,
runtimeStatus: parsed.runtimeStatus
))
case "update-instrument":
guard let instObj = obj["instance"] as? [String: Any],
let parsed = Self.parseWireInstrument(instObj)
else { return nil }
return .updateInstrument(UpdateInstrument(
opID: opID,
sessionID: sessionID,
instance: parsed.instance,
runtimeStatus: parsed.runtimeStatus
))
case "remove-instrument":
guard let instanceIDStr = obj["instance_id"] as? String,
let instanceID = UUID(uuidString: instanceIDStr)
else { return nil }
return .removeInstrument(RemoveInstrument(
opID: opID,
sessionID: sessionID,
instanceID: instanceID
))
case "add-insight":
guard let insightObj = obj["insight"] as? [String: Any],
let insight = AddressInsight.fromWireJSON(insightObj)
else { return nil }
return .addInsight(AddInsight(
opID: opID,
sessionID: sessionID,
insight: insight
))
case "update-insight":
guard let insightObj = obj["insight"] as? [String: Any],
let insight = AddressInsight.fromWireJSON(insightObj)
else { return nil }
return .updateInsight(UpdateInsight(
opID: opID,
sessionID: sessionID,
insight: insight
))
case "remove-insight":
guard let insightIDStr = obj["insight_id"] as? String,
let insightID = UUID(uuidString: insightIDStr)
else { return nil }
return .removeInsight(RemoveInsight(
opID: opID,
sessionID: sessionID,
insightID: insightID
))
case "upsert-trace":
guard let traceObj = obj["trace"] as? [String: Any],
let trace = ITrace.fromWireJSON(traceObj)
else { return nil }
return .upsertTrace(UpsertTrace(
opID: opID,
sessionID: sessionID,
trace: trace
))
case "remove-trace":
guard let traceIDStr = obj["trace_id"] as? String,
let traceID = UUID(uuidString: traceIDStr)
else { return nil }
return .removeTrace(RemoveTrace(
opID: opID,
sessionID: sessionID,
traceID: traceID
))
case "trace-data-progressed":
guard let traceIDStr = obj["trace_id"] as? String,
let traceID = UUID(uuidString: traceIDStr),
let totalSize = obj["total_size"] as? Int
else { return nil }
return .traceDataProgressed(TraceDataProgressed(
opID: opID,
sessionID: sessionID,
traceID: traceID,
totalSize: totalSize
))
case "remove":
return .remove(Remove(opID: opID, sessionID: sessionID))
default:
return nil
}
}
private static func wireInstrument(instance: InstrumentInstance, runtimeStatus: InstrumentStatus?) -> [String: Any]? {
guard var obj = instance.toWireJSON() else { return nil }
if let runtimeStatus {
obj["runtime_status"] = runtimeStatus.toWireJSON()
}
return obj
}
private static func parseWireInstrument(_ obj: [String: Any]) -> (instance: InstrumentInstance, runtimeStatus: InstrumentStatus?)? {
var stripped = obj
let statusObj = stripped.removeValue(forKey: "runtime_status") as? [String: Any]
guard let instance = InstrumentInstance.fromWireJSON(stripped) else { return nil }
let status = statusObj.flatMap(InstrumentStatus.fromWireJSON)
return (instance, status)
}
}
private nonisolated(unsafe) let armingTimestampFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
func encodeArmingState(_ state: ProcessSession.ArmingState) -> [String: Any] {
switch state {
case .unarmed:
return ["state": "unarmed"]
case .armed(let pattern, let armedAt):
return [
"state": "armed",
"match_pattern": pattern,
"armed_at": armingTimestampFormatter.string(from: armedAt),
]
}
}
func decodeArmingState(_ obj: [String: Any]) -> ProcessSession.ArmingState? {
guard let state = obj["state"] as? String else { return nil }
switch state {
case "unarmed":
return .unarmed
case "armed":
guard let pattern = obj["match_pattern"] as? String,
let armedAtStr = obj["armed_at"] as? String,
let armedAt = armingTimestampFormatter.date(from: armedAtStr)
else { return nil }
return .armed(matchPattern: pattern, armedAt: armedAt)
default:
return nil
}
}