From 85a6d1707c6b6a9abde2e0b064e9906a8c57b898 Mon Sep 17 00:00:00 2001 From: Enrico Piovesan Date: Sat, 11 Jul 2026 19:16:25 -0600 Subject: [PATCH] Extract TraverseCore Swift package for iOS and macOS Share SSE + sendCommand client and AppStateViewModel between Apple shells so both platforms follow the web-react runtime subscription pattern. Co-authored-by: Cursor --- .gitignore | 1 + AGENTS.md | 3 +- apps/traverse-starter/TraverseCore/.gitignore | 1 + .../TraverseCore/Package.swift | 20 ++ apps/traverse-starter/TraverseCore/README.md | 15 ++ .../TraverseCore/AppStateViewModel.swift | 199 +++++++++++++++++ .../TraverseCore/ServerDiscovery.swift | 47 ++++ .../Sources/TraverseCore/TraverseClient.swift | 144 +++++++++++++ .../TraverseCore/TraverseCommand.swift | 17 ++ .../Sources/TraverseCore/TraverseOutput.swift | 176 +++++++++++++++ .../TraverseCoreTests/TraverseCoreTests.swift | 204 ++++++++++++++++++ apps/traverse-starter/ios-swift/README.md | 29 ++- .../TraverseStarter.xcodeproj/project.pbxproj | 43 ++-- .../TraverseStarter/AppSettings.swift | 11 +- .../TraverseStarter/ContentView.swift | 54 +++-- .../TraverseStarter/ExecutionViewModel.swift | 148 ------------- .../TraverseStarter/TraverseClient.swift | 162 -------------- .../TraverseStarter/TraverseStarterApp.swift | 16 +- .../AppSettingsSmokeTests.swift | 13 ++ .../ExecutionViewModelTests.swift | 101 --------- .../TraverseClientTests.swift | 98 --------- apps/traverse-starter/macos-swift/README.md | 24 +-- .../project.pbxproj | 92 ++++---- .../TraverseStarterMac/AppSettings.swift | 9 +- .../TraverseStarterMac/ContentView.swift | 52 +++-- .../ExecutionViewModel.swift | 148 ------------- .../TraverseStarterMac/TraverseClient.swift | 162 -------------- .../TraverseStarterMacApp.swift | 32 ++- .../AppSettingsSmokeTests.swift | 13 ++ .../ExecutionViewModelTests.swift | 101 --------- .../TraverseClientTests.swift | 98 --------- 31 files changed, 1075 insertions(+), 1158 deletions(-) create mode 100644 apps/traverse-starter/TraverseCore/.gitignore create mode 100644 apps/traverse-starter/TraverseCore/Package.swift create mode 100644 apps/traverse-starter/TraverseCore/README.md create mode 100644 apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift create mode 100644 apps/traverse-starter/TraverseCore/Sources/TraverseCore/ServerDiscovery.swift create mode 100644 apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseClient.swift create mode 100644 apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseCommand.swift create mode 100644 apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift create mode 100644 apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/TraverseCoreTests.swift delete mode 100644 apps/traverse-starter/ios-swift/TraverseStarter/ExecutionViewModel.swift delete mode 100644 apps/traverse-starter/ios-swift/TraverseStarter/TraverseClient.swift create mode 100644 apps/traverse-starter/ios-swift/TraverseStarterTests/AppSettingsSmokeTests.swift delete mode 100644 apps/traverse-starter/ios-swift/TraverseStarterTests/ExecutionViewModelTests.swift delete mode 100644 apps/traverse-starter/ios-swift/TraverseStarterTests/TraverseClientTests.swift delete mode 100644 apps/traverse-starter/macos-swift/TraverseStarterMac/ExecutionViewModel.swift delete mode 100644 apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseClient.swift create mode 100644 apps/traverse-starter/macos-swift/TraverseStarterMacTests/AppSettingsSmokeTests.swift delete mode 100644 apps/traverse-starter/macos-swift/TraverseStarterMacTests/ExecutionViewModelTests.swift delete mode 100644 apps/traverse-starter/macos-swift/TraverseStarterMacTests/TraverseClientTests.swift diff --git a/.gitignore b/.gitignore index f551dbb..e57dc45 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ coverage/ # Traverse checkout symlink for Phase 2 manifest validation manifests/traverse-starter/_traverse .traverse/ +apps/traverse-starter/TraverseCore/.build/ diff --git a/AGENTS.md b/AGENTS.md index d95fde1..a5add94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,8 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \ - **Phase 3 embedded runtime** ([#109](https://github.com/traverse-framework/reference-apps/issues/109)–[#118](https://github.com/traverse-framework/reference-apps/issues/118)) — see `docs/embedded-runtime-plan.md`; blocked on a **consumable platform embedder SDK** (Traverse [#553](https://github.com/traverse-framework/Traverse/issues/553) closed via [#578](https://github.com/traverse-framework/Traverse/pull/578) with manifest `execution_mode` only — no web/Swift/etc. embedder package yet). Traverse [#615](https://github.com/traverse-framework/Traverse/pull/615) added wasm32 core build only — not a platform embedder package. - **doc-approval multi-capability showcase** ([#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112)) — blocked on Traverse [#538](https://github.com/traverse-framework/Traverse/issues/538) / [#555](https://github.com/traverse-framework/Traverse/issues/555) (`extract` / `recommend` agents) -- **traverse-starter.pipeline showcase** ([#110](https://github.com/traverse-framework/reference-apps/issues/110)) — blocked on Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) (pipeline execute surface from #600 never landed on `main`; validate/summarize agents are on `main`) -Ready: [#58](https://github.com/traverse-framework/reference-apps/issues/58)/[#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) — HTTP/SSE shared client extraction (Traverse #525–#527 + platform scaffolds done; [#43](https://github.com/traverse-framework/reference-apps/issues/43) web SSE shipped). +Ready: [#110](https://github.com/traverse-framework/reference-apps/issues/110) traverse-starter.pipeline (Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) closed); [#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) shared client packages ([#58](https://github.com/traverse-framework/reference-apps/issues/58) in progress). Update this section when a PR changes platform status (see PR template checklist). diff --git a/apps/traverse-starter/TraverseCore/.gitignore b/apps/traverse-starter/TraverseCore/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/apps/traverse-starter/TraverseCore/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/apps/traverse-starter/TraverseCore/Package.swift b/apps/traverse-starter/TraverseCore/Package.swift new file mode 100644 index 0000000..845a63a --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "TraverseCore", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "TraverseCore", targets: ["TraverseCore"]), + ], + targets: [ + .target(name: "TraverseCore"), + .testTarget( + name: "TraverseCoreTests", + dependencies: ["TraverseCore"] + ), + ] +) diff --git a/apps/traverse-starter/TraverseCore/README.md b/apps/traverse-starter/TraverseCore/README.md new file mode 100644 index 0000000..2c94386 --- /dev/null +++ b/apps/traverse-starter/TraverseCore/README.md @@ -0,0 +1,15 @@ +# TraverseCore + +Shared Swift package for traverse-starter **iOS** and **macOS** clients. + +- `TraverseClient` — health, `sendCommand`, SSE `subscribeAppEvents`, fetchTrace +- `AppStateViewModel` — `@MainActor ObservableObject` mapping runtime SSE payloads +- `TraverseOutput` / `TraverseCommand` — public types + parsers +- `ServerDiscovery` — reads `.traverse/server.json` on macOS only + +```bash +cd apps/traverse-starter/TraverseCore +swift test +``` + +No UIKit / AppKit imports. Platforms: iOS 17+, macOS 14+. diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift new file mode 100644 index 0000000..7781861 --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/AppStateViewModel.swift @@ -0,0 +1,199 @@ +import Combine +import Foundation + +public enum RuntimeStatus: Equatable, Sendable { + case checking + case online + case offline +} + +/// Subscribes to runtime app SSE and maps event payloads to render state. +/// Contains no local transition logic beyond applying runtime-owned fields. +@MainActor +public final class AppStateViewModel: ObservableObject { + @Published public private(set) var currentState: String = "idle" + @Published public private(set) var output: TraverseStarterOutput? + @Published public private(set) var errorMessage: String? + @Published public private(set) var connected: Bool = false + @Published public private(set) var sessionId: String? + @Published public private(set) var executionId: String? + @Published public private(set) var trace: [TraceEvent] = [] + @Published public private(set) var runtimeStatus: RuntimeStatus = .checking + @Published public private(set) var submitting: Bool = false + @Published public var note: String = "" + @Published public var showTrace: Bool = false + + public let appId: String + public let noteMaxLength: Int + + private let client: TraverseClientProtocol + private var baseURL: URL? + private var workspaceId: String + private var sseTask: Task? + private var healthTask: Task? + + public init( + client: TraverseClientProtocol, + baseURL: URL?, + workspaceId: String, + appId: String = "traverse-starter", + noteMaxLength: Int = 2000 + ) { + self.client = client + self.baseURL = baseURL + self.workspaceId = workspaceId + self.appId = appId + self.noteMaxLength = noteMaxLength + startHealthChecks() + startSSE() + } + + deinit { + sseTask?.cancel() + healthTask?.cancel() + } + + public var canSubmit: Bool { + runtimeStatus == .online && + !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !isRunning + } + + public var isRunning: Bool { + submitting || currentState == "processing" + } + + public func updateConnection(baseURL: URL?, workspaceId: String) { + let changed = self.baseURL != baseURL || self.workspaceId != workspaceId + self.baseURL = baseURL + self.workspaceId = workspaceId + if changed { + startSSE() + Task { await refreshHealth() } + } + } + + public func startHealthChecks() { + healthTask?.cancel() + healthTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshHealth() + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + } + } + + public func refreshHealth() async { + guard let baseURL else { + runtimeStatus = .offline + return + } + do { + let ok = try await client.checkHealth(baseURL: baseURL) + runtimeStatus = ok ? .online : .offline + } catch { + runtimeStatus = .offline + } + } + + public func submit() { + guard canSubmit, let baseURL else { return } + let trimmed = String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(noteMaxLength)) + submitting = true + errorMessage = nil + trace = [] + Task { [weak self] in + guard let self else { return } + do { + // New session each submit (omit session_id) so submit is valid from idle. + _ = try await client.sendCommand( + workspaceId: workspaceId, + appId: appId, + command: .submit(note: trimmed), + baseURL: baseURL + ) + } catch { + await MainActor.run { + self.errorMessage = String(describing: error) + self.submitting = false + } + return + } + await MainActor.run { self.submitting = false } + } + } + + public func resetLocal() { + currentState = "idle" + output = nil + errorMessage = nil + executionId = nil + sessionId = nil + trace = [] + showTrace = false + submitting = false + } + + private func startSSE() { + sseTask?.cancel() + connected = false + guard let baseURL else { return } + let workspaceId = self.workspaceId + let appId = self.appId + sseTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await client.subscribeAppEvents( + workspaceId: workspaceId, + appId: appId, + baseURL: baseURL + ) { [weak self] type, payload in + Task { @MainActor in + self?.apply(eventType: type, payload: payload) + } + } + } catch { + await MainActor.run { self.connected = false } + } + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } + } + + func apply(eventType: String, payload: AppStateEventPayload) { + if eventType == "heartbeat" { + connected = true + return + } + connected = true + if let state = payload.state, !state.isEmpty { + currentState = state + } + if let sessionId = payload.sessionId { + self.sessionId = sessionId + } + if let executionId = payload.executionId { + self.executionId = executionId + } + if let output = payload.output { + self.output = output + } + if let errorMessage = payload.errorMessage { + self.errorMessage = errorMessage + } else if payload.state == "results" { + self.errorMessage = nil + } + if currentState == "results", let executionId, let baseURL { + Task { [weak self] in + guard let self else { return } + let events = (try? await client.fetchTrace( + workspaceId: workspaceId, + executionId: executionId, + baseURL: baseURL + )) ?? [] + await MainActor.run { self.trace = events } + } + } + } +} diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/ServerDiscovery.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/ServerDiscovery.swift new file mode 100644 index 0000000..dab67d6 --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/ServerDiscovery.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Reads `.traverse/server.json` when available (macOS / desktop). Returns nil on iOS. +public enum ServerDiscovery { + public struct ServerInfo: Equatable, Sendable { + public let baseURL: URL + public let workspaceDefault: String + + public init(baseURL: URL, workspaceDefault: String) { + self.baseURL = baseURL + self.workspaceDefault = workspaceDefault + } + } + + public static func discover(startingAt directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)) -> ServerInfo? { + #if os(macOS) + var current = directory + for _ in 0..<8 { + let candidate = current.appendingPathComponent(".traverse/server.json") + if let info = read(at: candidate) { + return info + } + let parent = current.deletingLastPathComponent() + if parent.path == current.path { break } + current = parent + } + return nil + #else + return nil + #endif + } + + public static func read(at fileURL: URL) -> ServerInfo? { + #if os(macOS) + guard let data = try? Data(contentsOf: fileURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let base = json["base_url"] as? String, + let url = URL(string: base) else { + return nil + } + let workspace = (json["workspace_default"] as? String) ?? "local-default" + return ServerInfo(baseURL: url, workspaceDefault: workspace) + #else + return nil + #endif + } +} diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseClient.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseClient.swift new file mode 100644 index 0000000..d8e2d21 --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseClient.swift @@ -0,0 +1,144 @@ +import Foundation + +public protocol TraverseClientProtocol: Sendable { + func checkHealth(baseURL: URL) async throws -> Bool + func sendCommand( + workspaceId: String, + appId: String, + command: TraverseCommand, + baseURL: URL + ) async throws -> CommandAccepted + func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] + func appEventsURL(workspaceId: String, appId: String, baseURL: URL) -> URL + func subscribeAppEvents( + workspaceId: String, + appId: String, + baseURL: URL, + onEvent: @escaping @Sendable (String, AppStateEventPayload) -> Void + ) async throws +} + +public final class TraverseClient: TraverseClientProtocol, @unchecked Sendable { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func checkHealth(baseURL: URL) async throws -> Bool { + let url = baseURL.appendingPathComponent("healthz") + var request = URLRequest(url: url) + request.httpMethod = "GET" + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return false } + return http.statusCode == 200 + } + + public func sendCommand( + workspaceId: String, + appId: String, + command: TraverseCommand, + baseURL: URL + ) async throws -> CommandAccepted { + let url = baseURL + .appendingPathComponent("v1/workspaces/\(workspaceId)/apps/\(appId)/commands") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + var body: [String: Any] = [ + "command": command.name, + "payload": command.payload, + ] + if let sessionId = command.sessionId { + body["session_id"] = sessionId + } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } + guard (200..<300).contains(http.statusCode) else { + throw TraverseClientError.http(status: http.statusCode) + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let apiVersion = json["api_version"] as? String, + let status = json["status"] as? String, + let workspace = json["workspace_id"] as? String, + let app = json["app_id"] as? String, + let sessionId = json["session_id"] as? String, + let commandName = json["command"] as? String, + let state = json["state"] as? String else { + throw TraverseClientError.decode + } + return CommandAccepted( + apiVersion: apiVersion, + status: status, + workspaceId: workspace, + appId: app, + sessionId: sessionId, + command: commandName, + state: state, + executionId: json["execution_id"] as? String + ) + } + + public func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] { + let url = baseURL + .appendingPathComponent("v1/workspaces/\(workspaceId)/traces/\(executionId)") + var request = URLRequest(url: url) + request.httpMethod = "GET" + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } + guard http.statusCode == 200 else { throw TraverseClientError.http(status: http.statusCode) } + return try JSONDecoder().decode([TraceEvent].self, from: data) + } + + public func appEventsURL(workspaceId: String, appId: String, baseURL: URL) -> URL { + baseURL.appendingPathComponent("v1/workspaces/\(workspaceId)/apps/\(appId)/events") + } + + /// Streams app SSE events until cancelled. Calls `onEvent` for each named event. + public func subscribeAppEvents( + workspaceId: String, + appId: String, + baseURL: URL, + onEvent: @escaping @Sendable (String, AppStateEventPayload) -> Void + ) async throws { + let url = appEventsURL(workspaceId: workspaceId, appId: appId, baseURL: baseURL) + var request = URLRequest(url: url) + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + let (bytes, response) = try await session.bytes(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw TraverseClientError.http(status: http.statusCode) + } + + var eventType = "message" + var dataLines: [String] = [] + + for try await line in bytes.lines { + if Task.isCancelled { break } + if line.isEmpty { + if !dataLines.isEmpty { + let raw = dataLines.joined(separator: "\n") + dataLines = [] + let type = eventType + eventType = "message" + if type == "heartbeat" { + onEvent(type, AppStateEventPayload()) + continue + } + if let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data), + let payload = TraverseOutputParser.parseEventPayload(json) { + onEvent(type, payload) + } + } + continue + } + if line.hasPrefix(":") { continue } + if line.hasPrefix("event:") { + eventType = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces) + } else if line.hasPrefix("data:") { + dataLines.append(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces)) + } + } + } +} diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseCommand.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseCommand.swift new file mode 100644 index 0000000..79ec49e --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseCommand.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct TraverseCommand: Equatable, Sendable { + public let name: String + public let payload: [String: String] + public let sessionId: String? + + public init(name: String, payload: [String: String] = [:], sessionId: String? = nil) { + self.name = name + self.payload = payload + self.sessionId = sessionId + } + + public static func submit(note: String, sessionId: String? = nil) -> TraverseCommand { + TraverseCommand(name: "submit", payload: ["note": note], sessionId: sessionId) + } +} diff --git a/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift new file mode 100644 index 0000000..907c9be --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Sources/TraverseCore/TraverseOutput.swift @@ -0,0 +1,176 @@ +import Foundation + +public struct TraverseStarterOutput: Equatable, Sendable, Codable { + public let title: String + public let tags: [String] + public let noteType: String + public let suggestedNextAction: String + public let status: String + + public init( + title: String, + tags: [String], + noteType: String, + suggestedNextAction: String, + status: String + ) { + self.title = title + self.tags = tags + self.noteType = noteType + self.suggestedNextAction = suggestedNextAction + self.status = status + } +} + +public struct TraceEvent: Equatable, Sendable, Codable { + public let event_type: String + public let timestamp: String + public let data: JSONValue? + + public init(event_type: String, timestamp: String, data: JSONValue? = nil) { + self.event_type = event_type + self.timestamp = timestamp + self.data = data + } +} + +public struct CommandAccepted: Equatable, Sendable { + public let apiVersion: String + public let status: String + public let workspaceId: String + public let appId: String + public let sessionId: String + public let command: String + public let state: String + public let executionId: String? + + public init( + apiVersion: String, + status: String, + workspaceId: String, + appId: String, + sessionId: String, + command: String, + state: String, + executionId: String? + ) { + self.apiVersion = apiVersion + self.status = status + self.workspaceId = workspaceId + self.appId = appId + self.sessionId = sessionId + self.command = command + self.state = state + self.executionId = executionId + } +} + +public struct AppStateEventPayload: Equatable, Sendable { + public let state: String? + public let sessionId: String? + public let executionId: String? + public let output: TraverseStarterOutput? + public let errorMessage: String? + + public init( + state: String? = nil, + sessionId: String? = nil, + executionId: String? = nil, + output: TraverseStarterOutput? = nil, + errorMessage: String? = nil + ) { + self.state = state + self.sessionId = sessionId + self.executionId = executionId + self.output = output + self.errorMessage = errorMessage + } +} + +public enum TraverseClientError: Error, Equatable, Sendable { + case http(status: Int) + case decode + case invalidURL +} + +public enum JSONValue: Equatable, Sendable, Codable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +public enum TraverseOutputParser { + public static func parse(_ raw: Any?) -> TraverseStarterOutput? { + guard let dict = raw as? [String: Any], + let title = dict["title"] as? String, + let tags = dict["tags"] as? [String], + let noteType = dict["noteType"] as? String, + let suggestedNextAction = dict["suggestedNextAction"] as? String, + let status = dict["status"] as? String else { + return nil + } + return TraverseStarterOutput( + title: title, + tags: tags, + noteType: noteType, + suggestedNextAction: suggestedNextAction, + status: status + ) + } + + public static func parseEventPayload(_ raw: Any?) -> AppStateEventPayload? { + guard let dict = raw as? [String: Any] else { return nil } + let state = dict["state"] as? String + let sessionId = dict["session_id"] as? String + let executionId = dict["execution_id"] as? String + let output = parse(dict["output"]) + var errorMessage: String? + if let error = dict["error"] as? String { + errorMessage = error + } else if let errorObj = dict["error"] as? [String: Any], + let message = errorObj["message"] as? String { + errorMessage = message + } + return AppStateEventPayload( + state: state, + sessionId: sessionId, + executionId: executionId, + output: output, + errorMessage: errorMessage + ) + } +} diff --git a/apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/TraverseCoreTests.swift b/apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/TraverseCoreTests.swift new file mode 100644 index 0000000..fa07e33 --- /dev/null +++ b/apps/traverse-starter/TraverseCore/Tests/TraverseCoreTests/TraverseCoreTests.swift @@ -0,0 +1,204 @@ +import Foundation +import XCTest +@testable import TraverseCore + +final class MockURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +enum TestURLSessionFactory { + static func make() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) + } +} + +final class TraverseClientTests: XCTestCase { + func testCheckHealthReturnsTrueOn200() async throws { + MockURLProtocol.handler = { request in + XCTAssertTrue(request.url?.path.hasSuffix("/healthz") == true) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + let client = TraverseClient(session: TestURLSessionFactory.make()) + let ok = try await client.checkHealth(baseURL: URL(string: "http://127.0.0.1:8787")!) + XCTAssertTrue(ok) + } + + func testSendCommandPostsToAppsCommands() async throws { + MockURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertTrue(request.url?.path.contains("/apps/traverse-starter/commands") == true) + let response = HTTPURLResponse(url: request.url!, statusCode: 202, httpVersion: nil, headerFields: nil)! + let body = """ + {"api_version":"v1","status":"accepted","workspace_id":"local-default","app_id":"traverse-starter","session_id":"sess-1","command":"submit","state":"processing","execution_id":"exec-1"} + """.data(using: .utf8)! + return (response, body) + } + let client = TraverseClient(session: TestURLSessionFactory.make()) + let accepted = try await client.sendCommand( + workspaceId: "local-default", + appId: "traverse-starter", + command: .submit(note: "hello"), + baseURL: URL(string: "http://127.0.0.1:8787")! + ) + XCTAssertEqual(accepted.sessionId, "sess-1") + XCTAssertEqual(accepted.state, "processing") + XCTAssertEqual(accepted.executionId, "exec-1") + } + + func testAppEventsURL() { + let client = TraverseClient() + let url = client.appEventsURL( + workspaceId: "local-default", + appId: "traverse-starter", + baseURL: URL(string: "http://127.0.0.1:8787")! + ) + XCTAssertEqual(url.path, "/v1/workspaces/local-default/apps/traverse-starter/events") + } +} + +final class TraverseOutputTests: XCTestCase { + func testParseOutput() { + let raw: [String: Any] = [ + "title": "T", + "tags": ["a"], + "noteType": "n", + "suggestedNextAction": "x", + "status": "done", + ] + let output = TraverseOutputParser.parse(raw) + XCTAssertEqual(output?.title, "T") + XCTAssertEqual(output?.tags, ["a"]) + } + + func testParseEventPayload() { + let raw: [String: Any] = [ + "state": "results", + "session_id": "sess-1", + "execution_id": "exec-1", + "output": [ + "title": "T", + "tags": [] as [String], + "noteType": "n", + "suggestedNextAction": "x", + "status": "done", + ], + ] + let payload = TraverseOutputParser.parseEventPayload(raw) + XCTAssertEqual(payload?.state, "results") + XCTAssertEqual(payload?.output?.title, "T") + } +} + +@MainActor +final class AppStateViewModelTests: XCTestCase { + final class MockClient: TraverseClientProtocol, @unchecked Sendable { + var healthOK = true + func checkHealth(baseURL: URL) async throws -> Bool { healthOK } + func sendCommand( + workspaceId: String, + appId: String, + command: TraverseCommand, + baseURL: URL + ) async throws -> CommandAccepted { + CommandAccepted( + apiVersion: "v1", + status: "accepted", + workspaceId: workspaceId, + appId: appId, + sessionId: "sess-1", + command: command.name, + state: "processing", + executionId: "exec-1" + ) + } + func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] { + [] + } + func appEventsURL(workspaceId: String, appId: String, baseURL: URL) -> URL { + URL(string: "http://127.0.0.1:8787/v1/workspaces/\(workspaceId)/apps/\(appId)/events")! + } + func subscribeAppEvents( + workspaceId: String, + appId: String, + baseURL: URL, + onEvent: @escaping @Sendable (String, AppStateEventPayload) -> Void + ) async throws { + // Stay idle until cancelled for unit tests that drive apply() directly. + while !Task.isCancelled { + try await Task.sleep(nanoseconds: 100_000_000) + } + } + } + + func testHeartbeatMarksConnectedWithoutChangingState() { + let vm = AppStateViewModel( + client: MockClient(), + baseURL: URL(string: "http://127.0.0.1:8787"), + workspaceId: "local-default" + ) + vm.apply(eventType: "heartbeat", payload: AppStateEventPayload()) + XCTAssertTrue(vm.connected) + XCTAssertEqual(vm.currentState, "idle") + } + + func testCapabilityResultMapsToResults() { + let vm = AppStateViewModel( + client: MockClient(), + baseURL: URL(string: "http://127.0.0.1:8787"), + workspaceId: "local-default" + ) + vm.apply( + eventType: "capability_result", + payload: AppStateEventPayload( + state: "results", + sessionId: "sess-1", + executionId: "exec-1", + output: TraverseStarterOutput( + title: "T", + tags: [], + noteType: "n", + suggestedNextAction: "x", + status: "done" + ) + ) + ) + XCTAssertEqual(vm.currentState, "results") + XCTAssertEqual(vm.output?.title, "T") + XCTAssertEqual(vm.executionId, "exec-1") + } + + func testCanSubmitWhenOnlineWithNote() async { + let client = MockClient() + let vm = AppStateViewModel( + client: client, + baseURL: URL(string: "http://127.0.0.1:8787"), + workspaceId: "local-default" + ) + await vm.refreshHealth() + vm.note = "hello" + XCTAssertTrue(vm.canSubmit) + } +} diff --git a/apps/traverse-starter/ios-swift/README.md b/apps/traverse-starter/ios-swift/README.md index 585a0d7..e4971e9 100644 --- a/apps/traverse-starter/ios-swift/README.md +++ b/apps/traverse-starter/ios-swift/README.md @@ -1,6 +1,6 @@ # traverse-starter (iOS SwiftUI) -Native iOS client for the `traverse-starter` reference app. Phase 1 uses HTTP polling against the public Traverse HTTP/JSON API (spec 033) — same flow as `web-react`. +Native iOS client for the `traverse-starter` reference app. Uses shared [`TraverseCore`](../TraverseCore/) for HTTP command dispatch + SSE app state (same pattern as `web-react` after #43). ## Prerequisites @@ -31,24 +31,21 @@ xcodebuild -scheme TraverseStarter \ build test ``` +Package unit tests (preferred for client/SSE logic): + +```bash +cd apps/traverse-starter/TraverseCore && swift test +``` + Or open `TraverseStarter.xcodeproj` in Xcode and run on an iOS 17+ simulator. ## Architecture -| File | Role | +| File / package | Role | |---|---| -| `TraverseClient.swift` | URLSession HTTP client (execute, poll, trace, health) | -| `ExecutionViewModel.swift` | MVVM state machine: idle → loading → polling → done | -| `ContentView.swift` | Main UI — runtime strip, note input, output fields, trace | -| `SettingsView.swift` | Runtime URL + workspace | -| `AppSettings.swift` | UserDefaults persistence | - -The UI renders runtime-provided output fields only — no business logic in the client. - -## Phase 2 (not implemented) - -When Traverse ships SSE state subscription ([#525](https://github.com/traverse-framework/Traverse/issues/525)–[#527](https://github.com/traverse-framework/Traverse/issues/527)), replace polling with SSE and command dispatch. See issue [#44](https://github.com/traverse-framework/reference-apps/issues/44). - -## Design language +| [`../TraverseCore/`](../TraverseCore/) | Shared Swift package: `TraverseClient`, `AppStateViewModel`, output parsing, SSE | +| `AppSettings.swift` | UserDefaults persistence + app id | +| `ContentView.swift` | Three-zone UI (runtime / input / output) | +| `SettingsView.swift` | Runtime URL + workspace editor | -Follow [docs/design-language.md](../../../docs/design-language.md) for shared zone layout and field naming. +UI renders runtime-owned fields only — no business logic in the shell. diff --git a/apps/traverse-starter/ios-swift/TraverseStarter.xcodeproj/project.pbxproj b/apps/traverse-starter/ios-swift/TraverseStarter.xcodeproj/project.pbxproj index 2c51733..30e0a7b 100644 --- a/apps/traverse-starter/ios-swift/TraverseStarter.xcodeproj/project.pbxproj +++ b/apps/traverse-starter/ios-swift/TraverseStarter.xcodeproj/project.pbxproj @@ -9,25 +9,20 @@ /* Begin PBXBuildFile section */ A10000000000000000000001 /* TraverseStarterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* TraverseStarterApp.swift */; }; A10000000000000000000002 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AppSettings.swift */; }; - A10000000000000000000003 /* TraverseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* TraverseClient.swift */; }; - A10000000000000000000004 /* ExecutionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* ExecutionViewModel.swift */; }; A10000000000000000000005 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* ContentView.swift */; }; A10000000000000000000006 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* SettingsView.swift */; }; A10000000000000000000007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* Assets.xcassets */; }; - A10000000000000000000008 /* TraverseClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* TraverseClientTests.swift */; }; - A10000000000000000000009 /* ExecutionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* ExecutionViewModelTests.swift */; }; + A1000000000000000000000A /* AppSettingsSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppSettingsSmokeTests.swift */; }; + A1000000000000000000000B /* TraverseCore in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000C /* TraverseCore */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ A20000000000000000000001 /* TraverseStarterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraverseStarterApp.swift; sourceTree = ""; }; A20000000000000000000002 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - A20000000000000000000003 /* TraverseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraverseClient.swift; sourceTree = ""; }; - A20000000000000000000004 /* ExecutionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionViewModel.swift; sourceTree = ""; }; A20000000000000000000005 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A20000000000000000000006 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A20000000000000000000007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - A20000000000000000000008 /* TraverseClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraverseClientTests.swift; sourceTree = ""; }; - A20000000000000000000009 /* ExecutionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionViewModelTests.swift; sourceTree = ""; }; + A2000000000000000000000A /* AppSettingsSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsSmokeTests.swift; sourceTree = ""; }; A20000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A30000000000000000000001 /* TraverseStarter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TraverseStarter.app; sourceTree = BUILT_PRODUCTS_DIR; }; A30000000000000000000002 /* TraverseStarterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TraverseStarterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -38,6 +33,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A1000000000000000000000B /* TraverseCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,8 +61,6 @@ children = ( A20000000000000000000001 /* TraverseStarterApp.swift */, A20000000000000000000002 /* AppSettings.swift */, - A20000000000000000000003 /* TraverseClient.swift */, - A20000000000000000000004 /* ExecutionViewModel.swift */, A20000000000000000000005 /* ContentView.swift */, A20000000000000000000006 /* SettingsView.swift */, A20000000000000000000007 /* Assets.xcassets */, @@ -78,8 +72,7 @@ A50000000000000000000003 /* TraverseStarterTests */ = { isa = PBXGroup; children = ( - A20000000000000000000008 /* TraverseClientTests.swift */, - A20000000000000000000009 /* ExecutionViewModelTests.swift */, + A2000000000000000000000A /* AppSettingsSmokeTests.swift */, ); path = TraverseStarterTests; sourceTree = ""; @@ -109,6 +102,9 @@ dependencies = ( ); name = TraverseStarter; + packageProductDependencies = ( + A1000000000000000000000C /* TraverseCore */, + ); productName = TraverseStarter; productReference = A30000000000000000000001 /* TraverseStarter.app */; productType = "com.apple.product-type.application"; @@ -159,6 +155,9 @@ Base, ); mainGroup = A50000000000000000000001; + packageReferences = ( + A1000000000000000000000D /* XCLocalSwiftPackageReference "../TraverseCore" */, + ); productRefGroup = A50000000000000000000004 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -194,8 +193,6 @@ files = ( A10000000000000000000001 /* TraverseStarterApp.swift in Sources */, A10000000000000000000002 /* AppSettings.swift in Sources */, - A10000000000000000000003 /* TraverseClient.swift in Sources */, - A10000000000000000000004 /* ExecutionViewModel.swift in Sources */, A10000000000000000000005 /* ContentView.swift in Sources */, A10000000000000000000006 /* SettingsView.swift in Sources */, ); @@ -205,8 +202,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A10000000000000000000008 /* TraverseClientTests.swift in Sources */, - A10000000000000000000009 /* ExecutionViewModelTests.swift in Sources */, + A1000000000000000000000A /* AppSettingsSmokeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -376,6 +372,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A1000000000000000000000D /* XCLocalSwiftPackageReference "../TraverseCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../TraverseCore; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A1000000000000000000000C /* TraverseCore */ = { + isa = XCSwiftPackageProductDependency; + package = A1000000000000000000000D /* XCLocalSwiftPackageReference "../TraverseCore" */; + productName = TraverseCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = AA0000000000000000000001 /* Project object */; } diff --git a/apps/traverse-starter/ios-swift/TraverseStarter/AppSettings.swift b/apps/traverse-starter/ios-swift/TraverseStarter/AppSettings.swift index 0e1b252..2ca7d7c 100644 --- a/apps/traverse-starter/ios-swift/TraverseStarter/AppSettings.swift +++ b/apps/traverse-starter/ios-swift/TraverseStarter/AppSettings.swift @@ -1,8 +1,9 @@ import Foundation +import TraverseCore @MainActor final class AppSettings: ObservableObject { - static let capabilityId = "traverse-starter.process" + static let appId = "traverse-starter" static let noteMaxLength = 2000 static let defaultBaseURL = "http://127.0.0.1:8787" static let defaultWorkspace = "local-default" @@ -21,6 +22,14 @@ final class AppSettings: ObservableObject { } init(userDefaults: UserDefaults = .standard) { + #if os(macOS) + if userDefaults.string(forKey: Keys.baseURL) == nil, + let discovered = ServerDiscovery.discover() { + baseURLString = discovered.baseURL.absoluteString + workspace = discovered.workspaceDefault + return + } + #endif baseURLString = userDefaults.string(forKey: Keys.baseURL) ?? Self.defaultBaseURL workspace = userDefaults.string(forKey: Keys.workspace) ?? Self.defaultWorkspace } diff --git a/apps/traverse-starter/ios-swift/TraverseStarter/ContentView.swift b/apps/traverse-starter/ios-swift/TraverseStarter/ContentView.swift index 9c6d2ca..992910d 100644 --- a/apps/traverse-starter/ios-swift/TraverseStarter/ContentView.swift +++ b/apps/traverse-starter/ios-swift/TraverseStarter/ContentView.swift @@ -1,8 +1,9 @@ import SwiftUI +import TraverseCore struct ContentView: View { @EnvironmentObject private var settings: AppSettings - @EnvironmentObject private var viewModel: ExecutionViewModel + @EnvironmentObject private var viewModel: AppStateViewModel @State private var showSettings = false var body: some View { @@ -44,7 +45,7 @@ struct ContentView: View { Text("workspace: \(settings.workspace)") .font(.footnote) .foregroundStyle(.secondary) - Text("capability: \(AppSettings.capabilityId)") + Text("app: \(AppSettings.appId)") .font(.footnote) .foregroundStyle(.secondary) } @@ -80,28 +81,36 @@ struct ContentView: View { private var outputSection: some View { GroupBox("Execution Output") { VStack(alignment: .leading, spacing: 12) { - switch viewModel.phase { - case .idle: + if let error = viewModel.errorMessage, viewModel.currentState != "results" { + Text("Error: \(error)") + .foregroundStyle(.red) + } + + switch viewModel.currentState { + case "idle": if viewModel.runtimeStatus == .offline { Text("Connect to the Traverse runtime to see workflow output here.") .foregroundStyle(.secondary) - } else { + } else if viewModel.submitting { + Text("Submitting command…") + } else if viewModel.errorMessage == nil { Text("Submit a note above to start a workflow.") .foregroundStyle(.secondary) } - case .loading: - Text("Starting execution…") - case .polling(let executionId): - Text("Polling execution \(executionId)…") - .font(.footnote.monospaced()) - case .failed(let error): - Text("Error: \(error)") + case "processing": + Text("Processing…") + case "error": + Text("Error: \(viewModel.errorMessage ?? "execution failed")") .foregroundStyle(.red) - case .succeeded(let output, let trace): - outputFields(output) - if !trace.isEmpty { - DisclosureGroup("Trace (\(trace.count) events)", isExpanded: $viewModel.showTrace) { - ForEach(Array(trace.enumerated()), id: \.offset) { _, event in + Button("Reset") { viewModel.resetLocal() } + .buttonStyle(.bordered) + case "results": + if let output = viewModel.output { + outputFields(output) + } + if !viewModel.trace.isEmpty { + DisclosureGroup("Trace (\(viewModel.trace.count) events)", isExpanded: $viewModel.showTrace) { + ForEach(Array(viewModel.trace.enumerated()), id: \.offset) { _, event in VStack(alignment: .leading, spacing: 4) { Text("\(event.timestamp) · \(event.event_type)") .font(.caption.monospaced()) @@ -115,8 +124,11 @@ struct ContentView: View { } } } - Button("Reset") { viewModel.reset() } + Button("Reset") { viewModel.resetLocal() } .buttonStyle(.bordered) + default: + Text("State: \(viewModel.currentState)") + .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -161,5 +173,9 @@ struct ContentView: View { let settings = AppSettings() ContentView() .environmentObject(settings) - .environmentObject(ExecutionViewModel(client: TraverseClient(), settings: settings)) + .environmentObject(AppStateViewModel( + client: TraverseClient(), + baseURL: settings.baseURL, + workspaceId: settings.workspace + )) } diff --git a/apps/traverse-starter/ios-swift/TraverseStarter/ExecutionViewModel.swift b/apps/traverse-starter/ios-swift/TraverseStarter/ExecutionViewModel.swift deleted file mode 100644 index 534d754..0000000 --- a/apps/traverse-starter/ios-swift/TraverseStarter/ExecutionViewModel.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation - -enum ExecutionPhase: Equatable { - case idle - case loading - case polling(executionId: String) - case succeeded(output: TraverseStarterOutput, trace: [TraceEvent]) - case failed(error: String) -} - -enum RuntimeStatus: Equatable { - case checking - case online - case offline -} - -@MainActor -final class ExecutionViewModel: ObservableObject { - @Published private(set) var phase: ExecutionPhase = .idle - @Published var note: String = "" - @Published private(set) var runtimeStatus: RuntimeStatus = .checking - @Published var showTrace = false - - private let client: TraverseClientProtocol - private let settings: AppSettings - private var pollTask: Task? - private var healthTask: Task? - - private static let terminalStatuses: Set = ["succeeded", "failed"] - private static let pollIntervalNanoseconds: UInt64 = 1_000_000_000 - - init(client: TraverseClientProtocol, settings: AppSettings) { - self.client = client - self.settings = settings - startHealthChecks() - } - - deinit { - pollTask?.cancel() - healthTask?.cancel() - } - - var canSubmit: Bool { - runtimeStatus == .online && - !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !isRunning - } - - var isRunning: Bool { - switch phase { - case .loading, .polling: return true - default: return false - } - } - - func startHealthChecks() { - healthTask?.cancel() - healthTask = Task { [weak self] in - while !Task.isCancelled { - await self?.refreshHealth() - try? await Task.sleep(nanoseconds: 5_000_000_000) - } - } - } - - func refreshHealth() async { - guard let baseURL = settings.baseURL else { - runtimeStatus = .offline - return - } - runtimeStatus = .checking - do { - let ok = try await client.checkHealth(baseURL: baseURL) - runtimeStatus = ok ? .online : .offline - } catch { - runtimeStatus = .offline - } - } - - func submit() { - guard canSubmit, let baseURL = settings.baseURL else { return } - pollTask?.cancel() - phase = .loading - let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) - let workspace = settings.workspace - pollTask = Task { [weak self] in - guard let self else { return } - do { - let executionId = try await client.execute( - workspaceId: workspace, - capability: AppSettings.capabilityId, - input: ["note": trimmedNote], - baseURL: baseURL - ) - await MainActor.run { self.phase = .polling(executionId: executionId) } - try await self.pollUntilTerminal( - workspaceId: workspace, - executionId: executionId, - baseURL: baseURL - ) - } catch { - await MainActor.run { self.phase = .failed(error: String(describing: error)) } - } - } - } - - func reset() { - pollTask?.cancel() - pollTask = nil - phase = .idle - showTrace = false - } - - private func pollUntilTerminal(workspaceId: String, executionId: String, baseURL: URL) async throws { - while !Task.isCancelled { - let result = try await client.pollExecution( - workspaceId: workspaceId, - executionId: executionId, - baseURL: baseURL - ) - if Self.terminalStatuses.contains(result.status) { - if result.status == "succeeded" { - let trace = (try? await client.fetchTrace( - workspaceId: workspaceId, - executionId: executionId, - baseURL: baseURL - )) ?? [] - let output = result.output ?? TraverseStarterOutput( - title: "", - tags: [], - noteType: "", - suggestedNextAction: "", - status: "" - ) - await MainActor.run { - self.phase = .succeeded(output: output, trace: trace) - } - } else { - await MainActor.run { - self.phase = .failed(error: result.error ?? "execution failed") - } - } - return - } - try await Task.sleep(nanoseconds: Self.pollIntervalNanoseconds) - } - } -} diff --git a/apps/traverse-starter/ios-swift/TraverseStarter/TraverseClient.swift b/apps/traverse-starter/ios-swift/TraverseStarter/TraverseClient.swift deleted file mode 100644 index 5bb0628..0000000 --- a/apps/traverse-starter/ios-swift/TraverseStarter/TraverseClient.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation - -struct TraverseStarterOutput: Equatable, Codable { - let title: String - let tags: [String] - let noteType: String - let suggestedNextAction: String - let status: String -} - -struct TraceEvent: Equatable, Codable { - let event_type: String - let timestamp: String - let data: JSONValue? -} - -struct ExecutionPollResult: Equatable { - let executionId: String - let status: String - let output: TraverseStarterOutput? - let error: String? -} - -enum TraverseClientError: Error, Equatable { - case http(status: Int) - case decode - case invalidURL -} - -enum JSONValue: Equatable, Codable { - case string(String) - case number(Double) - case bool(Bool) - case object([String: JSONValue]) - case array([JSONValue]) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Double.self) { - self = .number(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([String: JSONValue].self) { - self = .object(value) - } else if let value = try? container.decode([JSONValue].self) { - self = .array(value) - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let value): try container.encode(value) - case .number(let value): try container.encode(value) - case .bool(let value): try container.encode(value) - case .object(let value): try container.encode(value) - case .array(let value): try container.encode(value) - case .null: try container.encodeNil() - } - } -} - -enum TraverseOutputParser { - static func parse(_ raw: Any?) -> TraverseStarterOutput? { - guard let dict = raw as? [String: Any], - let title = dict["title"] as? String, - let tags = dict["tags"] as? [String], - let noteType = dict["noteType"] as? String, - let suggestedNextAction = dict["suggestedNextAction"] as? String, - let status = dict["status"] as? String else { - return nil - } - return TraverseStarterOutput( - title: title, - tags: tags, - noteType: noteType, - suggestedNextAction: suggestedNextAction, - status: status - ) - } -} - -protocol TraverseClientProtocol: Sendable { - func checkHealth(baseURL: URL) async throws -> Bool - func execute(workspaceId: String, capability: String, input: [String: String], baseURL: URL) async throws -> String - func pollExecution(workspaceId: String, executionId: String, baseURL: URL) async throws -> ExecutionPollResult - func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] -} - -final class TraverseClient: TraverseClientProtocol, @unchecked Sendable { - private let session: URLSession - - init(session: URLSession = .shared) { - self.session = session - } - - func checkHealth(baseURL: URL) async throws -> Bool { - let url = baseURL.appendingPathComponent("healthz") - var request = URLRequest(url: url) - request.httpMethod = "GET" - let (_, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { return false } - return http.statusCode == 200 - } - - func execute(workspaceId: String, capability: String, input: [String: String], baseURL: URL) async throws -> String { - let url = baseURL - .appendingPathComponent("v1/workspaces/\(workspaceId)/execute") - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: ["capability": capability, "input": input]) - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } - guard http.statusCode == 200 else { throw TraverseClientError.http(status: http.statusCode) } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let executionId = json["execution_id"] as? String else { - throw TraverseClientError.decode - } - return executionId - } - - func pollExecution(workspaceId: String, executionId: String, baseURL: URL) async throws -> ExecutionPollResult { - let url = baseURL - .appendingPathComponent("v1/workspaces/\(workspaceId)/executions/\(executionId)") - var request = URLRequest(url: url) - request.httpMethod = "GET" - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } - guard http.statusCode == 200 else { throw TraverseClientError.http(status: http.statusCode) } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let status = json["status"] as? String else { - throw TraverseClientError.decode - } - let output = TraverseOutputParser.parse(json["output"]) - let error = json["error"] as? String - return ExecutionPollResult( - executionId: executionId, - status: status, - output: output, - error: error - ) - } - - func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] { - let url = baseURL - .appendingPathComponent("v1/workspaces/\(workspaceId)/traces/\(executionId)") - var request = URLRequest(url: url) - request.httpMethod = "GET" - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } - guard http.statusCode == 200 else { throw TraverseClientError.http(status: http.statusCode) } - return try JSONDecoder().decode([TraceEvent].self, from: data) - } -} diff --git a/apps/traverse-starter/ios-swift/TraverseStarter/TraverseStarterApp.swift b/apps/traverse-starter/ios-swift/TraverseStarter/TraverseStarterApp.swift index 8d10596..284fa25 100644 --- a/apps/traverse-starter/ios-swift/TraverseStarter/TraverseStarterApp.swift +++ b/apps/traverse-starter/ios-swift/TraverseStarter/TraverseStarterApp.swift @@ -1,16 +1,20 @@ import SwiftUI +import TraverseCore @main struct TraverseStarterApp: App { @StateObject private var settings = AppSettings() - @StateObject private var viewModel: ExecutionViewModel + @StateObject private var viewModel: AppStateViewModel init() { let settings = AppSettings() _settings = StateObject(wrappedValue: settings) - _viewModel = StateObject(wrappedValue: ExecutionViewModel( + _viewModel = StateObject(wrappedValue: AppStateViewModel( client: TraverseClient(), - settings: settings + baseURL: settings.baseURL, + workspaceId: settings.workspace, + appId: AppSettings.appId, + noteMaxLength: AppSettings.noteMaxLength )) } @@ -19,6 +23,12 @@ struct TraverseStarterApp: App { ContentView() .environmentObject(settings) .environmentObject(viewModel) + .onChange(of: settings.baseURLString) { _, _ in + viewModel.updateConnection(baseURL: settings.baseURL, workspaceId: settings.workspace) + } + .onChange(of: settings.workspace) { _, workspace in + viewModel.updateConnection(baseURL: settings.baseURL, workspaceId: workspace) + } } } } diff --git a/apps/traverse-starter/ios-swift/TraverseStarterTests/AppSettingsSmokeTests.swift b/apps/traverse-starter/ios-swift/TraverseStarterTests/AppSettingsSmokeTests.swift new file mode 100644 index 0000000..cca7403 --- /dev/null +++ b/apps/traverse-starter/ios-swift/TraverseStarterTests/AppSettingsSmokeTests.swift @@ -0,0 +1,13 @@ +import XCTest +@testable import TraverseStarter + +final class AppSettingsSmokeTests: XCTestCase { + @MainActor + func testDefaultWorkspaceAndAppId() { + let defaults = UserDefaults(suiteName: "traverse.starter.ios.smoke")! + defaults.removePersistentDomain(forName: "traverse.starter.ios.smoke") + let settings = AppSettings(userDefaults: defaults) + XCTAssertEqual(settings.workspace, AppSettings.defaultWorkspace) + XCTAssertEqual(AppSettings.appId, "traverse-starter") + } +} diff --git a/apps/traverse-starter/ios-swift/TraverseStarterTests/ExecutionViewModelTests.swift b/apps/traverse-starter/ios-swift/TraverseStarterTests/ExecutionViewModelTests.swift deleted file mode 100644 index c9d60f3..0000000 --- a/apps/traverse-starter/ios-swift/TraverseStarterTests/ExecutionViewModelTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import XCTest -@testable import TraverseStarter - -@MainActor -final class MockTraverseClient: TraverseClientProtocol { - var healthOK = true - var executionId = "exec_test" - var pollResults: [ExecutionPollResult] = [] - var pollIndex = 0 - var trace: [TraceEvent] = [] - - func checkHealth(baseURL: URL) async throws -> Bool { healthOK } - - func execute(workspaceId: String, capability: String, input: [String: String], baseURL: URL) async throws -> String { - executionId - } - - func pollExecution(workspaceId: String, executionId: String, baseURL: URL) async throws -> ExecutionPollResult { - defer { pollIndex += 1 } - if pollIndex < pollResults.count { - return pollResults[pollIndex] - } - return pollResults.last! - } - - func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] { - trace - } -} - -@MainActor -final class ExecutionViewModelTests: XCTestCase { - func testCanSubmitWhenOnlineWithNote() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.healthOK = true - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.note = "hello" - XCTAssertTrue(vm.canSubmit) - } - - func testSubmitTransitionsToSucceeded() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.pollResults = [ - ExecutionPollResult( - executionId: "exec_test", - status: "running", - output: nil, - error: nil - ), - ExecutionPollResult( - executionId: "exec_test", - status: "succeeded", - output: TraverseStarterOutput( - title: "Title", - tags: ["tag"], - noteType: "meeting", - suggestedNextAction: "follow up", - status: "processed" - ), - error: nil - ), - ] - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.note = "note text" - vm.submit() - try? await Task.sleep(nanoseconds: 2_000_000_000) - if case .succeeded(let output, _) = vm.phase { - XCTAssertEqual(output.title, "Title") - } else { - XCTFail("expected succeeded, got \(vm.phase)") - } - } - - func testResetReturnsToIdle() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.pollResults = [ - ExecutionPollResult( - executionId: "exec_test", - status: "failed", - output: nil, - error: "boom" - ), - ] - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.note = "note text" - vm.submit() - try? await Task.sleep(nanoseconds: 500_000_000) - guard case .failed = vm.phase else { - return XCTFail("expected failed, got \(vm.phase)") - } - vm.reset() - XCTAssertEqual(vm.phase, .idle) - } -} diff --git a/apps/traverse-starter/ios-swift/TraverseStarterTests/TraverseClientTests.swift b/apps/traverse-starter/ios-swift/TraverseStarterTests/TraverseClientTests.swift deleted file mode 100644 index 39fe0ef..0000000 --- a/apps/traverse-starter/ios-swift/TraverseStarterTests/TraverseClientTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation -import XCTest -@testable import TraverseStarter - -final class MockURLProtocol: URLProtocol { - static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let handler = Self.handler else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - do { - let (response, data) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() {} -} - -enum TestURLSessionFactory { - static func make() -> URLSession { - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [MockURLProtocol.self] - return URLSession(configuration: config) - } -} - -final class TraverseClientTests: XCTestCase { - func testCheckHealthReturnsTrueOn200() async throws { - MockURLProtocol.handler = { request in - XCTAssertEqual(request.url?.path, "/healthz") - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - return (response, Data()) - } - let client = TraverseClient(session: TestURLSessionFactory.make()) - let ok = try await client.checkHealth(baseURL: URL(string: "http://127.0.0.1:8787")!) - XCTAssertTrue(ok) - } - - func testExecuteReturnsExecutionId() async throws { - MockURLProtocol.handler = { request in - XCTAssertEqual(request.httpMethod, "POST") - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - let body = #"{"execution_id":"exec_abc"}"#.data(using: .utf8)! - return (response, body) - } - let client = TraverseClient(session: TestURLSessionFactory.make()) - let id = try await client.execute( - workspaceId: "local-default", - capability: "traverse-starter.process", - input: ["note": "hello"], - baseURL: URL(string: "http://127.0.0.1:8787")! - ) - XCTAssertEqual(id, "exec_abc") - } - - func testParseOutputFromPoll() async throws { - MockURLProtocol.handler = { _ in - let response = HTTPURLResponse( - url: URL(string: "http://127.0.0.1:8787")!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - let body = """ - {"execution_id":"exec_abc","status":"succeeded","output":{"title":"T","tags":["a"],"noteType":"n","suggestedNextAction":"x","status":"done"}} - """.data(using: .utf8)! - return (response, body) - } - let client = TraverseClient(session: TestURLSessionFactory.make()) - let result = try await client.pollExecution( - workspaceId: "local-default", - executionId: "exec_abc", - baseURL: URL(string: "http://127.0.0.1:8787")! - ) - XCTAssertEqual(result.status, "succeeded") - XCTAssertEqual(result.output?.title, "T") - } -} diff --git a/apps/traverse-starter/macos-swift/README.md b/apps/traverse-starter/macos-swift/README.md index 657da80..86c57ef 100644 --- a/apps/traverse-starter/macos-swift/README.md +++ b/apps/traverse-starter/macos-swift/README.md @@ -1,6 +1,6 @@ # traverse-starter (macOS SwiftUI) -Native macOS client for the `traverse-starter` reference app. Phase 1 uses HTTP polling against the public Traverse HTTP/JSON API (spec 033) — same flow as `web-react` and `ios-swift`. +Native macOS client for the `traverse-starter` reference app. Uses shared [`TraverseCore`](../TraverseCore/) for HTTP command dispatch + SSE app state (same pattern as `web-react` / `ios-swift`). ## Prerequisites @@ -17,7 +17,7 @@ cargo run -p traverse-cli -- serve Open **TraverseStarterMac → Settings…** (⌘,) and set: -- **Runtime URL** — default `http://127.0.0.1:8787` +- **Runtime URL** — default `http://127.0.0.1:8787` (auto-filled from `.traverse/server.json` when present) - **Workspace** — default `local-default` Values persist in UserDefaults. @@ -29,6 +29,12 @@ cd apps/traverse-starter/macos-swift xcodebuild -scheme TraverseStarterMac -destination 'platform=macOS' build test ``` +Package unit tests: + +```bash +cd apps/traverse-starter/TraverseCore && swift test +``` + Or open `TraverseStarterMac.xcodeproj` in Xcode and run on macOS 14+. ## Keyboard shortcuts @@ -41,18 +47,12 @@ Or open `TraverseStarterMac.xcodeproj` in Xcode and run on macOS 14+. ## Architecture -| File | Role | +| File / package | Role | |---|---| -| `TraverseClient.swift` | URLSession HTTP client (copied from ios-swift; shared package in #58) | -| `ExecutionViewModel.swift` | MVVM polling state machine | +| [`../TraverseCore/`](../TraverseCore/) | Shared Swift package: client, SSE, `AppStateViewModel` | | `ContentView.swift` | Main window — input, output, toolbar health strip | | `PreferencesView.swift` | Runtime settings (Settings scene) | | `AppDelegate.swift` | `NSApplicationDelegate` lifecycle | +| `AppSettings.swift` | UserDefaults + optional `ServerDiscovery` | -## Phase 2 (not implemented) - -SSE subscription when Traverse ships [#525](https://github.com/traverse-framework/Traverse/issues/525)–[#527](https://github.com/traverse-framework/Traverse/issues/527). Extract shared `TraverseCore` with ios-swift ([#58](https://github.com/traverse-framework/reference-apps/issues/58)). - -## Design language - -Follow [docs/design-language.md](../../../docs/design-language.md). +UI renders runtime-owned fields only — no business logic in the shell. diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMac.xcodeproj/project.pbxproj b/apps/traverse-starter/macos-swift/TraverseStarterMac.xcodeproj/project.pbxproj index 5814255..583ace6 100644 --- a/apps/traverse-starter/macos-swift/TraverseStarterMac.xcodeproj/project.pbxproj +++ b/apps/traverse-starter/macos-swift/TraverseStarterMac.xcodeproj/project.pbxproj @@ -9,28 +9,23 @@ /* Begin PBXBuildFile section */ B10000000000000000000001 /* TraverseStarterMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* TraverseStarterMacApp.swift */; }; B10000000000000000000002 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* AppSettings.swift */; }; - B10000000000000000000003 /* TraverseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000003 /* TraverseClient.swift */; }; - B10000000000000000000004 /* ExecutionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000004 /* ExecutionViewModel.swift */; }; B10000000000000000000005 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* ContentView.swift */; }; B10000000000000000000006 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* PreferencesView.swift */; }; - B10000000000000000000007 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* AppDelegate.swift */; }; - B10000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* Assets.xcassets */; }; - B10000000000000000000009 /* TraverseClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000009 /* TraverseClientTests.swift */; }; - B10000000000000000000010 /* ExecutionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000010 /* ExecutionViewModelTests.swift */; }; + B1000000000000000000000E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* AppDelegate.swift */; }; + B10000000000000000000007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* Assets.xcassets */; }; + B1000000000000000000000A /* AppSettingsSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000A /* AppSettingsSmokeTests.swift */; }; + B1000000000000000000000B /* TraverseCore in Frameworks */ = {isa = PBXBuildFile; productRef = B1000000000000000000000C /* TraverseCore */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ B20000000000000000000001 /* TraverseStarterMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraverseStarterMacApp.swift; sourceTree = ""; }; B20000000000000000000002 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - B20000000000000000000003 /* TraverseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraverseClient.swift; sourceTree = ""; }; - B20000000000000000000004 /* ExecutionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionViewModel.swift; sourceTree = ""; }; B20000000000000000000005 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; B20000000000000000000006 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; - B20000000000000000000007 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - B20000000000000000000008 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B20000000000000000000009 /* TraverseClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TraverseClientTests.swift; sourceTree = ""; }; - B20000000000000000000010 /* ExecutionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionViewModelTests.swift; sourceTree = ""; }; - B20000000000000000000011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B2000000000000000000000E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + B20000000000000000000007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B2000000000000000000000A /* AppSettingsSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsSmokeTests.swift; sourceTree = ""; }; + B20000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B30000000000000000000001 /* TraverseStarterMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TraverseStarterMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; B30000000000000000000002 /* TraverseStarterMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TraverseStarterMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -40,6 +35,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B1000000000000000000000B /* TraverseCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -56,24 +52,22 @@ B50000000000000000000001 = { isa = PBXGroup; children = ( - B50000000000000000000002 /* TraverseStarterMac */, + B50000000000000000000002 /* TraverseStarter */, B50000000000000000000003 /* TraverseStarterMacTests */, B50000000000000000000004 /* Products */, ); sourceTree = ""; }; - B50000000000000000000002 /* TraverseStarterMac */ = { + B50000000000000000000002 /* TraverseStarter */ = { isa = PBXGroup; children = ( B20000000000000000000001 /* TraverseStarterMacApp.swift */, - B20000000000000000000007 /* AppDelegate.swift */, + B2000000000000000000000E /* AppDelegate.swift */, B20000000000000000000002 /* AppSettings.swift */, - B20000000000000000000003 /* TraverseClient.swift */, - B20000000000000000000004 /* ExecutionViewModel.swift */, B20000000000000000000005 /* ContentView.swift */, B20000000000000000000006 /* PreferencesView.swift */, - B20000000000000000000008 /* Assets.xcassets */, - B20000000000000000000011 /* Info.plist */, + B20000000000000000000007 /* Assets.xcassets */, + B20000000000000000000010 /* Info.plist */, ); path = TraverseStarterMac; sourceTree = ""; @@ -81,8 +75,7 @@ B50000000000000000000003 /* TraverseStarterMacTests */ = { isa = PBXGroup; children = ( - B20000000000000000000009 /* TraverseClientTests.swift */, - B20000000000000000000010 /* ExecutionViewModelTests.swift */, + B2000000000000000000000A /* AppSettingsSmokeTests.swift */, ); path = TraverseStarterMacTests; sourceTree = ""; @@ -99,7 +92,7 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - B60000000000000000000001 /* TraverseStarterMac */ = { + B60000000000000000000001 /* TraverseStarter */ = { isa = PBXNativeTarget; buildConfigurationList = B80000000000000000000001 /* Build configuration list for PBXNativeTarget "TraverseStarterMac" */; buildPhases = ( @@ -112,6 +105,9 @@ dependencies = ( ); name = TraverseStarterMac; + packageProductDependencies = ( + B1000000000000000000000C /* TraverseCore */, + ); productName = TraverseStarterMac; productReference = B30000000000000000000001 /* TraverseStarterMac.app */; productType = "com.apple.product-type.application"; @@ -162,11 +158,14 @@ Base, ); mainGroup = B50000000000000000000001; + packageReferences = ( + B1000000000000000000000D /* XCLocalSwiftPackageReference "../TraverseCore" */, + ); productRefGroup = B50000000000000000000004 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - B60000000000000000000001 /* TraverseStarterMac */, + B60000000000000000000001 /* TraverseStarter */, B60000000000000000000002 /* TraverseStarterMacTests */, ); }; @@ -177,7 +176,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B10000000000000000000008 /* Assets.xcassets in Resources */, + B10000000000000000000007 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -196,12 +195,10 @@ buildActionMask = 2147483647; files = ( B10000000000000000000001 /* TraverseStarterMacApp.swift in Sources */, - B10000000000000000000007 /* AppDelegate.swift in Sources */, B10000000000000000000002 /* AppSettings.swift in Sources */, - B10000000000000000000003 /* TraverseClient.swift in Sources */, - B10000000000000000000004 /* ExecutionViewModel.swift in Sources */, B10000000000000000000005 /* ContentView.swift in Sources */, B10000000000000000000006 /* PreferencesView.swift in Sources */, + B1000000000000000000000E /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -209,8 +206,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B10000000000000000000009 /* TraverseClientTests.swift in Sources */, - B10000000000000000000010 /* ExecutionViewModelTests.swift in Sources */, + B1000000000000000000000A /* AppSettingsSmokeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -219,7 +215,7 @@ /* Begin PBXTargetDependency section */ B90000000000000000000001 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = B60000000000000000000001 /* TraverseStarterMac */; + target = B60000000000000000000001 /* TraverseStarter */; targetProxy = B90000000000000000000002 /* PBXContainerItemProxy */; }; B90000000000000000000002 /* PBXContainerItemProxy */ = { @@ -275,21 +271,21 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = TraverseStarterMac/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "@executable_path/../Frameworks", + "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.starter.mac; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = macosx; + COMBINE_HIDPI_IMAGES = YES; SWIFT_EMIT_LOC_STRINGS = YES; - }; + }; name = Debug; }; BB0000000000000000000004 /* Release */ = { @@ -297,19 +293,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = TraverseStarterMac/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "@executable_path/../Frameworks", + "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.starter.mac; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = macosx; + COMBINE_HIDPI_IMAGES = YES; SWIFT_EMIT_LOC_STRINGS = YES; - }; + }; name = Release; }; BB0000000000000000000005 /* Debug */ = { @@ -324,7 +321,7 @@ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.starter.mac.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TraverseStarterMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TraverseStarterMac"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TraverseStarterMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TraverseStarterMac"; }; name = Debug; }; @@ -340,7 +337,7 @@ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.starter.mac.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TraverseStarterMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TraverseStarterMac"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TraverseStarterMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TraverseStarterMac"; }; name = Release; }; @@ -375,6 +372,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + B1000000000000000000000D /* XCLocalSwiftPackageReference "../TraverseCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../TraverseCore; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B1000000000000000000000C /* TraverseCore */ = { + isa = XCSwiftPackageProductDependency; + package = B1000000000000000000000D /* XCLocalSwiftPackageReference "../TraverseCore" */; + productName = TraverseCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = BA0000000000000000000001 /* Project object */; } diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMac/AppSettings.swift b/apps/traverse-starter/macos-swift/TraverseStarterMac/AppSettings.swift index 0e1b252..b72565f 100644 --- a/apps/traverse-starter/macos-swift/TraverseStarterMac/AppSettings.swift +++ b/apps/traverse-starter/macos-swift/TraverseStarterMac/AppSettings.swift @@ -1,8 +1,9 @@ import Foundation +import TraverseCore @MainActor final class AppSettings: ObservableObject { - static let capabilityId = "traverse-starter.process" + static let appId = "traverse-starter" static let noteMaxLength = 2000 static let defaultBaseURL = "http://127.0.0.1:8787" static let defaultWorkspace = "local-default" @@ -21,6 +22,12 @@ final class AppSettings: ObservableObject { } init(userDefaults: UserDefaults = .standard) { + if userDefaults.string(forKey: Keys.baseURL) == nil, + let discovered = ServerDiscovery.discover() { + baseURLString = discovered.baseURL.absoluteString + workspace = discovered.workspaceDefault + return + } baseURLString = userDefaults.string(forKey: Keys.baseURL) ?? Self.defaultBaseURL workspace = userDefaults.string(forKey: Keys.workspace) ?? Self.defaultWorkspace } diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMac/ContentView.swift b/apps/traverse-starter/macos-swift/TraverseStarterMac/ContentView.swift index 00e6ed4..aa0de4b 100644 --- a/apps/traverse-starter/macos-swift/TraverseStarterMac/ContentView.swift +++ b/apps/traverse-starter/macos-swift/TraverseStarterMac/ContentView.swift @@ -1,8 +1,9 @@ import SwiftUI +import TraverseCore struct ContentView: View { @EnvironmentObject private var settings: AppSettings - @EnvironmentObject private var viewModel: ExecutionViewModel + @EnvironmentObject private var viewModel: AppStateViewModel var body: some View { NavigationSplitView { @@ -38,7 +39,7 @@ struct ContentView: View { Section("Runtime Environment") { LabeledContent("Status", value: statusLabel) LabeledContent("Workspace", value: settings.workspace) - LabeledContent("Capability", value: AppSettings.capabilityId) + LabeledContent("App", value: AppSettings.appId) } } .navigationSplitViewColumnWidth(min: 200, ideal: 220) @@ -58,7 +59,7 @@ struct ContentView: View { Button("Start Workflow") { viewModel.submit() } .keyboardShortcut(.return, modifiers: .command) .disabled(!viewModel.canSubmit) - Button("Reset") { viewModel.reset() } + Button("Reset") { viewModel.resetLocal() } .keyboardShortcut("r", modifiers: .command) } if viewModel.runtimeStatus == .offline { @@ -74,28 +75,34 @@ struct ContentView: View { private var outputSection: some View { GroupBox("Execution Output") { VStack(alignment: .leading, spacing: 12) { - switch viewModel.phase { - case .idle: + if let error = viewModel.errorMessage, viewModel.currentState != "results" { + Text("Error: \(error)") + .foregroundStyle(.red) + } + + switch viewModel.currentState { + case "idle": if viewModel.runtimeStatus == .offline { Text("Connect to the Traverse runtime to see workflow output here.") .foregroundStyle(.secondary) - } else { + } else if viewModel.submitting { + Text("Submitting command…") + } else if viewModel.errorMessage == nil { Text("Submit a note above to start a workflow (⌘↩).") .foregroundStyle(.secondary) } - case .loading: - Text("Starting execution…") - case .polling(let executionId): - Text("Polling execution \(executionId)…") - .font(.footnote.monospaced()) - case .failed(let error): - Text("Error: \(error)") + case "processing": + Text("Processing…") + case "error": + Text("Error: \(viewModel.errorMessage ?? "execution failed")") .foregroundStyle(.red) - case .succeeded(let output, let trace): - outputFields(output) - if !trace.isEmpty { - DisclosureGroup("Trace (\(trace.count) events)", isExpanded: $viewModel.showTrace) { - ForEach(Array(trace.enumerated()), id: \.offset) { _, event in + case "results": + if let output = viewModel.output { + outputFields(output) + } + if !viewModel.trace.isEmpty { + DisclosureGroup("Trace (\(viewModel.trace.count) events)", isExpanded: $viewModel.showTrace) { + ForEach(Array(viewModel.trace.enumerated()), id: \.offset) { _, event in VStack(alignment: .leading, spacing: 4) { Text("\(event.timestamp) · \(event.event_type)") .font(.caption.monospaced()) @@ -109,6 +116,9 @@ struct ContentView: View { } } } + default: + Text("State: \(viewModel.currentState)") + .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -161,5 +171,9 @@ struct ContentView: View { let settings = AppSettings() ContentView() .environmentObject(settings) - .environmentObject(ExecutionViewModel(client: TraverseClient(), settings: settings)) + .environmentObject(AppStateViewModel( + client: TraverseClient(), + baseURL: settings.baseURL, + workspaceId: settings.workspace + )) } diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMac/ExecutionViewModel.swift b/apps/traverse-starter/macos-swift/TraverseStarterMac/ExecutionViewModel.swift deleted file mode 100644 index 534d754..0000000 --- a/apps/traverse-starter/macos-swift/TraverseStarterMac/ExecutionViewModel.swift +++ /dev/null @@ -1,148 +0,0 @@ -import Foundation - -enum ExecutionPhase: Equatable { - case idle - case loading - case polling(executionId: String) - case succeeded(output: TraverseStarterOutput, trace: [TraceEvent]) - case failed(error: String) -} - -enum RuntimeStatus: Equatable { - case checking - case online - case offline -} - -@MainActor -final class ExecutionViewModel: ObservableObject { - @Published private(set) var phase: ExecutionPhase = .idle - @Published var note: String = "" - @Published private(set) var runtimeStatus: RuntimeStatus = .checking - @Published var showTrace = false - - private let client: TraverseClientProtocol - private let settings: AppSettings - private var pollTask: Task? - private var healthTask: Task? - - private static let terminalStatuses: Set = ["succeeded", "failed"] - private static let pollIntervalNanoseconds: UInt64 = 1_000_000_000 - - init(client: TraverseClientProtocol, settings: AppSettings) { - self.client = client - self.settings = settings - startHealthChecks() - } - - deinit { - pollTask?.cancel() - healthTask?.cancel() - } - - var canSubmit: Bool { - runtimeStatus == .online && - !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !isRunning - } - - var isRunning: Bool { - switch phase { - case .loading, .polling: return true - default: return false - } - } - - func startHealthChecks() { - healthTask?.cancel() - healthTask = Task { [weak self] in - while !Task.isCancelled { - await self?.refreshHealth() - try? await Task.sleep(nanoseconds: 5_000_000_000) - } - } - } - - func refreshHealth() async { - guard let baseURL = settings.baseURL else { - runtimeStatus = .offline - return - } - runtimeStatus = .checking - do { - let ok = try await client.checkHealth(baseURL: baseURL) - runtimeStatus = ok ? .online : .offline - } catch { - runtimeStatus = .offline - } - } - - func submit() { - guard canSubmit, let baseURL = settings.baseURL else { return } - pollTask?.cancel() - phase = .loading - let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) - let workspace = settings.workspace - pollTask = Task { [weak self] in - guard let self else { return } - do { - let executionId = try await client.execute( - workspaceId: workspace, - capability: AppSettings.capabilityId, - input: ["note": trimmedNote], - baseURL: baseURL - ) - await MainActor.run { self.phase = .polling(executionId: executionId) } - try await self.pollUntilTerminal( - workspaceId: workspace, - executionId: executionId, - baseURL: baseURL - ) - } catch { - await MainActor.run { self.phase = .failed(error: String(describing: error)) } - } - } - } - - func reset() { - pollTask?.cancel() - pollTask = nil - phase = .idle - showTrace = false - } - - private func pollUntilTerminal(workspaceId: String, executionId: String, baseURL: URL) async throws { - while !Task.isCancelled { - let result = try await client.pollExecution( - workspaceId: workspaceId, - executionId: executionId, - baseURL: baseURL - ) - if Self.terminalStatuses.contains(result.status) { - if result.status == "succeeded" { - let trace = (try? await client.fetchTrace( - workspaceId: workspaceId, - executionId: executionId, - baseURL: baseURL - )) ?? [] - let output = result.output ?? TraverseStarterOutput( - title: "", - tags: [], - noteType: "", - suggestedNextAction: "", - status: "" - ) - await MainActor.run { - self.phase = .succeeded(output: output, trace: trace) - } - } else { - await MainActor.run { - self.phase = .failed(error: result.error ?? "execution failed") - } - } - return - } - try await Task.sleep(nanoseconds: Self.pollIntervalNanoseconds) - } - } -} diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseClient.swift b/apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseClient.swift deleted file mode 100644 index 5bb0628..0000000 --- a/apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseClient.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation - -struct TraverseStarterOutput: Equatable, Codable { - let title: String - let tags: [String] - let noteType: String - let suggestedNextAction: String - let status: String -} - -struct TraceEvent: Equatable, Codable { - let event_type: String - let timestamp: String - let data: JSONValue? -} - -struct ExecutionPollResult: Equatable { - let executionId: String - let status: String - let output: TraverseStarterOutput? - let error: String? -} - -enum TraverseClientError: Error, Equatable { - case http(status: Int) - case decode - case invalidURL -} - -enum JSONValue: Equatable, Codable { - case string(String) - case number(Double) - case bool(Bool) - case object([String: JSONValue]) - case array([JSONValue]) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Double.self) { - self = .number(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([String: JSONValue].self) { - self = .object(value) - } else if let value = try? container.decode([JSONValue].self) { - self = .array(value) - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .string(let value): try container.encode(value) - case .number(let value): try container.encode(value) - case .bool(let value): try container.encode(value) - case .object(let value): try container.encode(value) - case .array(let value): try container.encode(value) - case .null: try container.encodeNil() - } - } -} - -enum TraverseOutputParser { - static func parse(_ raw: Any?) -> TraverseStarterOutput? { - guard let dict = raw as? [String: Any], - let title = dict["title"] as? String, - let tags = dict["tags"] as? [String], - let noteType = dict["noteType"] as? String, - let suggestedNextAction = dict["suggestedNextAction"] as? String, - let status = dict["status"] as? String else { - return nil - } - return TraverseStarterOutput( - title: title, - tags: tags, - noteType: noteType, - suggestedNextAction: suggestedNextAction, - status: status - ) - } -} - -protocol TraverseClientProtocol: Sendable { - func checkHealth(baseURL: URL) async throws -> Bool - func execute(workspaceId: String, capability: String, input: [String: String], baseURL: URL) async throws -> String - func pollExecution(workspaceId: String, executionId: String, baseURL: URL) async throws -> ExecutionPollResult - func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] -} - -final class TraverseClient: TraverseClientProtocol, @unchecked Sendable { - private let session: URLSession - - init(session: URLSession = .shared) { - self.session = session - } - - func checkHealth(baseURL: URL) async throws -> Bool { - let url = baseURL.appendingPathComponent("healthz") - var request = URLRequest(url: url) - request.httpMethod = "GET" - let (_, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { return false } - return http.statusCode == 200 - } - - func execute(workspaceId: String, capability: String, input: [String: String], baseURL: URL) async throws -> String { - let url = baseURL - .appendingPathComponent("v1/workspaces/\(workspaceId)/execute") - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: ["capability": capability, "input": input]) - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } - guard http.statusCode == 200 else { throw TraverseClientError.http(status: http.statusCode) } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let executionId = json["execution_id"] as? String else { - throw TraverseClientError.decode - } - return executionId - } - - func pollExecution(workspaceId: String, executionId: String, baseURL: URL) async throws -> ExecutionPollResult { - let url = baseURL - .appendingPathComponent("v1/workspaces/\(workspaceId)/executions/\(executionId)") - var request = URLRequest(url: url) - request.httpMethod = "GET" - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } - guard http.statusCode == 200 else { throw TraverseClientError.http(status: http.statusCode) } - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let status = json["status"] as? String else { - throw TraverseClientError.decode - } - let output = TraverseOutputParser.parse(json["output"]) - let error = json["error"] as? String - return ExecutionPollResult( - executionId: executionId, - status: status, - output: output, - error: error - ) - } - - func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] { - let url = baseURL - .appendingPathComponent("v1/workspaces/\(workspaceId)/traces/\(executionId)") - var request = URLRequest(url: url) - request.httpMethod = "GET" - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw TraverseClientError.decode } - guard http.statusCode == 200 else { throw TraverseClientError.http(status: http.statusCode) } - return try JSONDecoder().decode([TraceEvent].self, from: data) - } -} diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseStarterMacApp.swift b/apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseStarterMacApp.swift index 20d2493..0aa6758 100644 --- a/apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseStarterMacApp.swift +++ b/apps/traverse-starter/macos-swift/TraverseStarterMac/TraverseStarterMacApp.swift @@ -1,25 +1,26 @@ import SwiftUI +import TraverseCore -private struct ExecutionViewModelKey: FocusedValueKey { - typealias Value = ExecutionViewModel +private struct AppStateViewModelKey: FocusedValueKey { + typealias Value = AppStateViewModel } extension FocusedValues { - var executionViewModel: ExecutionViewModel? { - get { self[ExecutionViewModelKey.self] } - set { self[ExecutionViewModelKey.self] = newValue } + var appStateViewModel: AppStateViewModel? { + get { self[AppStateViewModelKey.self] } + set { self[AppStateViewModelKey.self] = newValue } } } struct WorkflowCommands: Commands { - @FocusedValue(\.executionViewModel) private var viewModel + @FocusedValue(\.appStateViewModel) private var viewModel var body: some Commands { CommandMenu("Workflow") { Button("Submit Note") { viewModel?.submit() } .keyboardShortcut(.return, modifiers: .command) .disabled(viewModel?.canSubmit != true) - Button("Reset") { viewModel?.reset() } + Button("Reset") { viewModel?.resetLocal() } .keyboardShortcut("r", modifiers: .command) } } @@ -29,14 +30,17 @@ struct WorkflowCommands: Commands { struct TraverseStarterMacApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var settings = AppSettings() - @StateObject private var viewModel: ExecutionViewModel + @StateObject private var viewModel: AppStateViewModel init() { let settings = AppSettings() _settings = StateObject(wrappedValue: settings) - _viewModel = StateObject(wrappedValue: ExecutionViewModel( + _viewModel = StateObject(wrappedValue: AppStateViewModel( client: TraverseClient(), - settings: settings + baseURL: settings.baseURL, + workspaceId: settings.workspace, + appId: AppSettings.appId, + noteMaxLength: AppSettings.noteMaxLength )) } @@ -45,7 +49,13 @@ struct TraverseStarterMacApp: App { ContentView() .environmentObject(settings) .environmentObject(viewModel) - .focusedValue(\.executionViewModel, viewModel) + .focusedValue(\.appStateViewModel, viewModel) + .onChange(of: settings.baseURLString) { _, _ in + viewModel.updateConnection(baseURL: settings.baseURL, workspaceId: settings.workspace) + } + .onChange(of: settings.workspace) { _, workspace in + viewModel.updateConnection(baseURL: settings.baseURL, workspaceId: workspace) + } } .commands { WorkflowCommands() diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMacTests/AppSettingsSmokeTests.swift b/apps/traverse-starter/macos-swift/TraverseStarterMacTests/AppSettingsSmokeTests.swift new file mode 100644 index 0000000..dd958b8 --- /dev/null +++ b/apps/traverse-starter/macos-swift/TraverseStarterMacTests/AppSettingsSmokeTests.swift @@ -0,0 +1,13 @@ +import XCTest +@testable import TraverseStarterMac + +final class AppSettingsSmokeTests: XCTestCase { + @MainActor + func testDefaultWorkspaceAndAppId() { + let defaults = UserDefaults(suiteName: "traverse.starter.mac.smoke")! + defaults.removePersistentDomain(forName: "traverse.starter.mac.smoke") + let settings = AppSettings(userDefaults: defaults) + XCTAssertEqual(settings.workspace, AppSettings.defaultWorkspace) + XCTAssertEqual(AppSettings.appId, "traverse-starter") + } +} diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMacTests/ExecutionViewModelTests.swift b/apps/traverse-starter/macos-swift/TraverseStarterMacTests/ExecutionViewModelTests.swift deleted file mode 100644 index ccec690..0000000 --- a/apps/traverse-starter/macos-swift/TraverseStarterMacTests/ExecutionViewModelTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import XCTest -@testable import TraverseStarterMac - -@MainActor -final class MockTraverseClient: TraverseClientProtocol { - var healthOK = true - var executionId = "exec_test" - var pollResults: [ExecutionPollResult] = [] - var pollIndex = 0 - var trace: [TraceEvent] = [] - - func checkHealth(baseURL: URL) async throws -> Bool { healthOK } - - func execute(workspaceId: String, capability: String, input: [String: String], baseURL: URL) async throws -> String { - executionId - } - - func pollExecution(workspaceId: String, executionId: String, baseURL: URL) async throws -> ExecutionPollResult { - defer { pollIndex += 1 } - if pollIndex < pollResults.count { - return pollResults[pollIndex] - } - return pollResults.last! - } - - func fetchTrace(workspaceId: String, executionId: String, baseURL: URL) async throws -> [TraceEvent] { - trace - } -} - -@MainActor -final class ExecutionViewModelTests: XCTestCase { - func testCanSubmitWhenOnlineWithNote() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.healthOK = true - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.note = "hello" - XCTAssertTrue(vm.canSubmit) - } - - func testSubmitTransitionsToSucceeded() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.pollResults = [ - ExecutionPollResult( - executionId: "exec_test", - status: "running", - output: nil, - error: nil - ), - ExecutionPollResult( - executionId: "exec_test", - status: "succeeded", - output: TraverseStarterOutput( - title: "Title", - tags: ["tag"], - noteType: "meeting", - suggestedNextAction: "follow up", - status: "processed" - ), - error: nil - ), - ] - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.note = "note text" - vm.submit() - try? await Task.sleep(nanoseconds: 2_000_000_000) - if case .succeeded(let output, _) = vm.phase { - XCTAssertEqual(output.title, "Title") - } else { - XCTFail("expected succeeded, got \(vm.phase)") - } - } - - func testResetReturnsToIdle() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.pollResults = [ - ExecutionPollResult( - executionId: "exec_test", - status: "failed", - output: nil, - error: "boom" - ), - ] - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.note = "note text" - vm.submit() - try? await Task.sleep(nanoseconds: 500_000_000) - guard case .failed = vm.phase else { - return XCTFail("expected failed, got \(vm.phase)") - } - vm.reset() - XCTAssertEqual(vm.phase, .idle) - } -} diff --git a/apps/traverse-starter/macos-swift/TraverseStarterMacTests/TraverseClientTests.swift b/apps/traverse-starter/macos-swift/TraverseStarterMacTests/TraverseClientTests.swift deleted file mode 100644 index 5a5b67c..0000000 --- a/apps/traverse-starter/macos-swift/TraverseStarterMacTests/TraverseClientTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation -import XCTest -@testable import TraverseStarterMac - -final class MockURLProtocol: URLProtocol { - static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? - - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let handler = Self.handler else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - do { - let (response, data) = try handler(request) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { - client?.urlProtocol(self, didFailWithError: error) - } - } - - override func stopLoading() {} -} - -enum TestURLSessionFactory { - static func make() -> URLSession { - let config = URLSessionConfiguration.ephemeral - config.protocolClasses = [MockURLProtocol.self] - return URLSession(configuration: config) - } -} - -final class TraverseClientTests: XCTestCase { - func testCheckHealthReturnsTrueOn200() async throws { - MockURLProtocol.handler = { request in - XCTAssertEqual(request.url?.path, "/healthz") - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - return (response, Data()) - } - let client = TraverseClient(session: TestURLSessionFactory.make()) - let ok = try await client.checkHealth(baseURL: URL(string: "http://127.0.0.1:8787")!) - XCTAssertTrue(ok) - } - - func testExecuteReturnsExecutionId() async throws { - MockURLProtocol.handler = { request in - XCTAssertEqual(request.httpMethod, "POST") - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - let body = #"{"execution_id":"exec_abc"}"#.data(using: .utf8)! - return (response, body) - } - let client = TraverseClient(session: TestURLSessionFactory.make()) - let id = try await client.execute( - workspaceId: "local-default", - capability: "traverse-starter.process", - input: ["note": "hello"], - baseURL: URL(string: "http://127.0.0.1:8787")! - ) - XCTAssertEqual(id, "exec_abc") - } - - func testParseOutputFromPoll() async throws { - MockURLProtocol.handler = { _ in - let response = HTTPURLResponse( - url: URL(string: "http://127.0.0.1:8787")!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - let body = """ - {"execution_id":"exec_abc","status":"succeeded","output":{"title":"T","tags":["a"],"noteType":"n","suggestedNextAction":"x","status":"done"}} - """.data(using: .utf8)! - return (response, body) - } - let client = TraverseClient(session: TestURLSessionFactory.make()) - let result = try await client.pollExecution( - workspaceId: "local-default", - executionId: "exec_abc", - baseURL: URL(string: "http://127.0.0.1:8787")! - ) - XCTAssertEqual(result.status, "succeeded") - XCTAssertEqual(result.output?.title, "T") - } -}