diff --git a/AGENTS.md b/AGENTS.md index 6b3de10..e66cf15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ 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) -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); [#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) Done; [#59](https://github.com/traverse-framework/reference-apps/issues/59) in this PR). +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); [#73](https://github.com/traverse-framework/reference-apps/issues/73) shared Rust client package ([#58](https://github.com/traverse-framework/reference-apps/issues/58)/[#59](https://github.com/traverse-framework/reference-apps/issues/59) Done; [#72](https://github.com/traverse-framework/reference-apps/issues/72) in this PR). Update this section when a PR changes platform status (see PR template checklist). diff --git a/README.md b/README.md index 237b0f4..1f4cf78 100644 --- a/README.md +++ b/README.md @@ -175,4 +175,4 @@ Active blockers on [Project 2](https://github.com/orgs/traverse-framework/projec - **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)) — Ready (Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) closed; pipeline on Traverse `main`). -Ready on Project 2: [#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 HTTP/SSE client packages ([#58](https://github.com/traverse-framework/reference-apps/issues/58) Done). +Ready on Project 2: [#73](https://github.com/traverse-framework/reference-apps/issues/73)/[#110](https://github.com/traverse-framework/reference-apps/issues/110) — shared Rust client + pipeline showcase ([#58](https://github.com/traverse-framework/reference-apps/issues/58)/[#59](https://github.com/traverse-framework/reference-apps/issues/59) Done; [#72](https://github.com/traverse-framework/reference-apps/issues/72) in progress). diff --git a/apps/doc-approval/DocApprovalCore/.gitignore b/apps/doc-approval/DocApprovalCore/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/apps/doc-approval/DocApprovalCore/Package.swift b/apps/doc-approval/DocApprovalCore/Package.swift new file mode 100644 index 0000000..3b934cf --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "DocApprovalCore", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "DocApprovalCore", targets: ["DocApprovalCore"]), + ], + targets: [ + .target(name: "DocApprovalCore"), + .testTarget( + name: "DocApprovalCoreTests", + dependencies: ["DocApprovalCore"] + ), + ] +) diff --git a/apps/doc-approval/DocApprovalCore/README.md b/apps/doc-approval/DocApprovalCore/README.md new file mode 100644 index 0000000..d7a0737 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/README.md @@ -0,0 +1,8 @@ +# DocApprovalCore + +Shared Swift package for doc-approval iOS and macOS shells: HTTP command dispatch, SSE app-state events, session listing, and output parsing. Platform-neutral — no UIKit or AppKit. + +```bash +cd apps/doc-approval/DocApprovalCore +swift test +``` diff --git a/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/AppStateViewModel.swift b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/AppStateViewModel.swift new file mode 100644 index 0000000..9b66d9a --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/AppStateViewModel.swift @@ -0,0 +1,197 @@ +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. +@MainActor +public final class AppStateViewModel: ObservableObject { + @Published public private(set) var currentState: String = "idle" + @Published public private(set) var output: DocApprovalOutput? + @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 document: String = "" + @Published public var showTrace: Bool = false + + public let appId: String + public let documentMaxLength: Int + + private let client: DocApprovalClientProtocol + private var baseURL: URL? + private var workspaceId: String + private var sseTask: Task? + private var healthTask: Task? + + public init( + client: DocApprovalClientProtocol, + baseURL: URL?, + workspaceId: String, + appId: String = "doc-approval", + documentMaxLength: Int = 10_000 + ) { + self.client = client + self.baseURL = baseURL + self.workspaceId = workspaceId + self.appId = appId + self.documentMaxLength = documentMaxLength + startHealthChecks() + startSSE() + } + + deinit { + sseTask?.cancel() + healthTask?.cancel() + } + + public var canSubmit: Bool { + runtimeStatus == .online && + !document.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(document.trimmingCharacters(in: .whitespacesAndNewlines).prefix(documentMaxLength)) + submitting = true + errorMessage = nil + trace = [] + Task { [weak self] in + guard let self else { return } + do { + _ = try await client.sendCommand( + workspaceId: workspaceId, + appId: appId, + command: .submit(document: 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/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalClient.swift b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalClient.swift new file mode 100644 index 0000000..915bfc8 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalClient.swift @@ -0,0 +1,217 @@ +import Foundation + +public protocol DocApprovalClientProtocol: Sendable { + func checkHealth(baseURL: URL) async throws -> Bool + func sendCommand( + workspaceId: String, + appId: String, + command: DocApprovalCommand, + baseURL: URL + ) async throws -> CommandAccepted + /// Session-scoped command dispatch (`sessionId` is required). + func sendCommand( + sessionId: String, + command: String, + payload: [String: String], + workspaceId: String, + appId: String, + baseURL: URL + ) async throws -> CommandAccepted + func listSessions( + workspaceId: String, + appId: String, + state: String?, + baseURL: URL + ) async throws -> [ApprovalSession] + 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 DocApprovalClient: DocApprovalClientProtocol, @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: DocApprovalCommand, + baseURL: URL + ) async throws -> CommandAccepted { + try await postCommand( + sessionId: command.sessionId, + command: command.name, + payload: command.payload, + workspaceId: workspaceId, + appId: appId, + baseURL: baseURL + ) + } + + public func sendCommand( + sessionId: String, + command: String, + payload: [String: String], + workspaceId: String, + appId: String, + baseURL: URL + ) async throws -> CommandAccepted { + try await postCommand( + sessionId: sessionId, + command: command, + payload: payload, + workspaceId: workspaceId, + appId: appId, + baseURL: baseURL + ) + } + + private func postCommand( + sessionId: String?, + command: String, + payload: [String: String], + workspaceId: String, + appId: String, + 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, + "payload": payload, + ] + if let 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 DocApprovalClientError.decode } + guard (200..<300).contains(http.statusCode) else { + throw DocApprovalClientError.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 acceptedSession = json["session_id"] as? String, + let commandName = json["command"] as? String, + let state = json["state"] as? String else { + throw DocApprovalClientError.decode + } + return CommandAccepted( + apiVersion: apiVersion, + status: status, + workspaceId: workspace, + appId: app, + sessionId: acceptedSession, + command: commandName, + state: state, + executionId: json["execution_id"] as? String + ) + } + + public func listSessions( + workspaceId: String, + appId: String, + state: String?, + baseURL: URL + ) async throws -> [ApprovalSession] { + var components = URLComponents( + url: baseURL.appendingPathComponent("v1/workspaces/\(workspaceId)/apps/\(appId)/sessions"), + resolvingAgainstBaseURL: false + ) + if let state, !state.isEmpty { + components?.queryItems = [URLQueryItem(name: "state", value: state)] + } + guard let url = components?.url else { throw DocApprovalClientError.invalidURL } + 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 DocApprovalClientError.decode } + guard http.statusCode == 200 else { throw DocApprovalClientError.http(status: http.statusCode) } + let json = try JSONSerialization.jsonObject(with: data) + return DocApprovalOutputParser.parseSessions(json) + } + + 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 DocApprovalClientError.decode } + guard http.statusCode == 200 else { throw DocApprovalClientError.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") + } + + 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 DocApprovalClientError.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 = DocApprovalOutputParser.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/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalCommand.swift b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalCommand.swift new file mode 100644 index 0000000..05d2f32 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalCommand.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct DocApprovalCommand: 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(document: String, sessionId: String? = nil) -> DocApprovalCommand { + DocApprovalCommand(name: "submit", payload: ["document": document], sessionId: sessionId) + } + + public static func approve(sessionId: String) -> DocApprovalCommand { + DocApprovalCommand(name: "approve", payload: [:], sessionId: sessionId) + } + + public static func reject(sessionId: String, reason: String = "") -> DocApprovalCommand { + var payload: [String: String] = [:] + if !reason.isEmpty { payload["reason"] = reason } + return DocApprovalCommand(name: "reject", payload: payload, sessionId: sessionId) + } +} diff --git a/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalOutput.swift b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalOutput.swift new file mode 100644 index 0000000..8d502e2 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/DocApprovalOutput.swift @@ -0,0 +1,225 @@ +import Foundation + +public struct DocApprovalOutput: Equatable, Sendable, Codable { + public let docType: String + public let parties: [String] + public let amounts: [String] + public let confidence: Double + public let recommendation: String + + public init( + docType: String, + parties: [String], + amounts: [String], + confidence: Double, + recommendation: String + ) { + self.docType = docType + self.parties = parties + self.amounts = amounts + self.confidence = confidence + self.recommendation = recommendation + } +} + +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: DocApprovalOutput? + public let errorMessage: String? + + public init( + state: String? = nil, + sessionId: String? = nil, + executionId: String? = nil, + output: DocApprovalOutput? = nil, + errorMessage: String? = nil + ) { + self.state = state + self.sessionId = sessionId + self.executionId = executionId + self.output = output + self.errorMessage = errorMessage + } +} + +public struct ApprovalSession: Equatable, Sendable, Identifiable, Codable { + public var id: String { sessionId } + public let sessionId: String + public let state: String + public let title: String? + + public init(sessionId: String, state: String, title: String? = nil) { + self.sessionId = sessionId + self.state = state + self.title = title + } + + enum CodingKeys: String, CodingKey { + case sessionId = "session_id" + case state + case title + } +} + +public enum DocApprovalClientError: 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 DocApprovalOutputParser { + public static func parse(_ raw: Any?) -> DocApprovalOutput? { + guard let dict = raw as? [String: Any], + let docType = dict["docType"] as? String, + let parties = dict["parties"] as? [String], + let amounts = dict["amounts"] as? [String], + let recommendation = dict["recommendation"] as? String else { + return nil + } + let confidence: Double + if let value = dict["confidence"] as? Double { + confidence = value + } else if let value = dict["confidence"] as? Int { + confidence = Double(value) + } else if let value = dict["confidence"] as? NSNumber { + confidence = value.doubleValue + } else { + return nil + } + return DocApprovalOutput( + docType: docType, + parties: parties, + amounts: amounts, + confidence: confidence, + recommendation: recommendation + ) + } + + 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 + ) + } + + public static func parseSessions(_ raw: Any?) -> [ApprovalSession] { + let items: [[String: Any]] + if let array = raw as? [[String: Any]] { + items = array + } else if let dict = raw as? [String: Any], + let array = dict["sessions"] as? [[String: Any]] { + items = array + } else { + return [] + } + return items.compactMap { item in + let sessionId = (item["session_id"] as? String) ?? (item["id"] as? String) + guard let sessionId, let state = item["state"] as? String else { return nil } + return ApprovalSession( + sessionId: sessionId, + state: state, + title: item["title"] as? String + ) + } + } +} diff --git a/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/SessionDiscovery.swift b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/SessionDiscovery.swift new file mode 100644 index 0000000..4cae642 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/SessionDiscovery.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Reads `.traverse/server.json` when available (macOS / desktop). Returns nil on iOS. +public enum SessionDiscovery { + 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/doc-approval/DocApprovalCore/Sources/DocApprovalCore/SessionListViewModel.swift b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/SessionListViewModel.swift new file mode 100644 index 0000000..d7d2b34 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Sources/DocApprovalCore/SessionListViewModel.swift @@ -0,0 +1,73 @@ +import Combine +import Foundation + +/// Polls pending-review sessions for the approver surface. +@MainActor +public final class SessionListViewModel: ObservableObject { + @Published public private(set) var sessions: [ApprovalSession] = [] + @Published public private(set) var errorMessage: String? + @Published public private(set) var loading: Bool = false + + public let appId: String + public let stateFilter: String + + private let client: DocApprovalClientProtocol + private var baseURL: URL? + private var workspaceId: String + private var pollTask: Task? + + public init( + client: DocApprovalClientProtocol, + baseURL: URL?, + workspaceId: String, + appId: String = "doc-approval", + stateFilter: String = "pending_review" + ) { + self.client = client + self.baseURL = baseURL + self.workspaceId = workspaceId + self.appId = appId + self.stateFilter = stateFilter + startPolling() + } + + deinit { + pollTask?.cancel() + } + + public func updateConnection(baseURL: URL?, workspaceId: String) { + self.baseURL = baseURL + self.workspaceId = workspaceId + Task { await refresh() } + } + + public func startPolling() { + pollTask?.cancel() + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refresh() + try? await Task.sleep(nanoseconds: 10_000_000_000) + } + } + } + + public func refresh() async { + guard let baseURL else { + sessions = [] + return + } + loading = true + defer { loading = false } + do { + sessions = try await client.listSessions( + workspaceId: workspaceId, + appId: appId, + state: stateFilter, + baseURL: baseURL + ) + errorMessage = nil + } catch { + errorMessage = String(describing: error) + } + } +} diff --git a/apps/doc-approval/DocApprovalCore/Tests/DocApprovalCoreTests/DocApprovalCoreTests.swift b/apps/doc-approval/DocApprovalCore/Tests/DocApprovalCoreTests/DocApprovalCoreTests.swift new file mode 100644 index 0000000..e00be74 --- /dev/null +++ b/apps/doc-approval/DocApprovalCore/Tests/DocApprovalCoreTests/DocApprovalCoreTests.swift @@ -0,0 +1,301 @@ +import Foundation +import XCTest +@testable import DocApprovalCore + +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 DocApprovalClientTests: 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 = DocApprovalClient(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/doc-approval/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":"doc-approval","session_id":"sess-1","command":"submit","state":"processing","execution_id":"exec-1"} + """.data(using: .utf8)! + return (response, body) + } + let client = DocApprovalClient(session: TestURLSessionFactory.make()) + let accepted = try await client.sendCommand( + workspaceId: "local-default", + appId: "doc-approval", + command: .submit(document: "hello"), + baseURL: URL(string: "http://127.0.0.1:8787")! + ) + XCTAssertEqual(accepted.sessionId, "sess-1") + XCTAssertEqual(accepted.state, "processing") + } + + func testSessionScopedSendCommandIncludesSessionId() async throws { + MockURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertTrue(request.url?.path.contains("/apps/doc-approval/commands") == true) + if let bodyData = request.httpBody, + let body = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] { + XCTAssertEqual(body["session_id"] as? String, "sess-approve") + XCTAssertEqual(body["command"] as? String, "approve") + } + let response = HTTPURLResponse(url: request.url!, statusCode: 202, httpVersion: nil, headerFields: nil)! + let data = """ + {"api_version":"v1","status":"accepted","workspace_id":"local-default","app_id":"doc-approval","session_id":"sess-approve","command":"approve","state":"approved"} + """.data(using: .utf8)! + return (response, data) + } + let client = DocApprovalClient(session: TestURLSessionFactory.make()) + let accepted = try await client.sendCommand( + sessionId: "sess-approve", + command: "approve", + payload: [:], + workspaceId: "local-default", + appId: "doc-approval", + baseURL: URL(string: "http://127.0.0.1:8787")! + ) + XCTAssertEqual(accepted.command, "approve") + } + + func testListSessionsParsesPendingReview() async throws { + MockURLProtocol.handler = { request in + XCTAssertTrue(request.url?.path.contains("/apps/doc-approval/sessions") == true) + XCTAssertEqual(request.url?.query, "state=pending_review") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + let body = """ + {"sessions":[{"session_id":"s1","state":"pending_review","title":"Contract"}]} + """.data(using: .utf8)! + return (response, body) + } + let client = DocApprovalClient(session: TestURLSessionFactory.make()) + let sessions = try await client.listSessions( + workspaceId: "local-default", + appId: "doc-approval", + state: "pending_review", + baseURL: URL(string: "http://127.0.0.1:8787")! + ) + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions[0].sessionId, "s1") + XCTAssertEqual(sessions[0].title, "Contract") + } + + func testAppEventsURL() { + let client = DocApprovalClient() + let url = client.appEventsURL( + workspaceId: "local-default", + appId: "doc-approval", + baseURL: URL(string: "http://127.0.0.1:8787")! + ) + XCTAssertEqual(url.path, "/v1/workspaces/local-default/apps/doc-approval/events") + } +} + +final class DocApprovalOutputTests: XCTestCase { + func testParseOutput() { + let raw: [String: Any] = [ + "docType": "nda", + "parties": ["A", "B"], + "amounts": ["$1"], + "confidence": 0.9, + "recommendation": "approve", + ] + let output = DocApprovalOutputParser.parse(raw) + XCTAssertEqual(output?.docType, "nda") + XCTAssertEqual(output?.recommendation, "approve") + } + + func testParseEventPayload() { + let raw: [String: Any] = [ + "state": "results", + "session_id": "sess-1", + "execution_id": "exec-1", + "output": [ + "docType": "nda", + "parties": [] as [String], + "amounts": [] as [String], + "confidence": 0.5, + "recommendation": "review", + ], + ] + let payload = DocApprovalOutputParser.parseEventPayload(raw) + XCTAssertEqual(payload?.state, "results") + XCTAssertEqual(payload?.output?.docType, "nda") + } + + func testParseSessionsArray() { + let raw: [[String: Any]] = [ + ["session_id": "s1", "state": "pending_review"], + ] + let sessions = DocApprovalOutputParser.parseSessions(raw) + XCTAssertEqual(sessions.first?.sessionId, "s1") + } +} + +@MainActor +final class AppStateViewModelTests: XCTestCase { + final class MockClient: DocApprovalClientProtocol, @unchecked Sendable { + var healthOK = true + var listed: [ApprovalSession] = [] + func checkHealth(baseURL: URL) async throws -> Bool { healthOK } + func sendCommand( + workspaceId: String, + appId: String, + command: DocApprovalCommand, + 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 sendCommand( + sessionId: String, + command: String, + payload: [String: String], + workspaceId: String, + appId: String, + baseURL: URL + ) async throws -> CommandAccepted { + CommandAccepted( + apiVersion: "v1", + status: "accepted", + workspaceId: workspaceId, + appId: appId, + sessionId: sessionId, + command: command, + state: "processing", + executionId: nil + ) + } + func listSessions( + workspaceId: String, + appId: String, + state: String?, + baseURL: URL + ) async throws -> [ApprovalSession] { + listed + } + 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 { + 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: DocApprovalOutput( + docType: "nda", + parties: [], + amounts: [], + confidence: 0.8, + recommendation: "approve" + ) + ) + ) + XCTAssertEqual(vm.currentState, "results") + XCTAssertEqual(vm.output?.docType, "nda") + } + + func testCanSubmitWhenOnlineWithDocument() async { + let vm = AppStateViewModel( + client: MockClient(), + baseURL: URL(string: "http://127.0.0.1:8787"), + workspaceId: "local-default" + ) + await vm.refreshHealth() + vm.document = "hello" + XCTAssertTrue(vm.canSubmit) + } +} + +@MainActor +final class SessionListViewModelTests: XCTestCase { + func testRefreshLoadsSessions() async { + let client = AppStateViewModelTests.MockClient() + client.listed = [ApprovalSession(sessionId: "s1", state: "pending_review", title: "T")] + let vm = SessionListViewModel( + client: client, + baseURL: URL(string: "http://127.0.0.1:8787"), + workspaceId: "local-default" + ) + await vm.refresh() + XCTAssertEqual(vm.sessions.count, 1) + XCTAssertEqual(vm.sessions[0].title, "T") + } +} diff --git a/apps/doc-approval/ios-swift/DocApproval.xcodeproj/project.pbxproj b/apps/doc-approval/ios-swift/DocApproval.xcodeproj/project.pbxproj index 1eef496..ec88ffd 100644 --- a/apps/doc-approval/ios-swift/DocApproval.xcodeproj/project.pbxproj +++ b/apps/doc-approval/ios-swift/DocApproval.xcodeproj/project.pbxproj @@ -7,27 +7,22 @@ objects = { /* Begin PBXBuildFile section */ + A1000000000000000000000B /* DocApprovalCore in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000C /* DocApprovalCore */; }; + A1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000E /* AppSettingsSmokeTests.swift */; }; A10000000000000000000001 /* DocApprovalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* DocApprovalApp.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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + A2000000000000000000000E /* AppSettingsSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsSmokeTests.swift; sourceTree = ""; }; A20000000000000000000001 /* DocApprovalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocApprovalApp.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 = ""; }; A20000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A30000000000000000000001 /* DocApproval.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DocApproval.app; sourceTree = BUILT_PRODUCTS_DIR; }; A30000000000000000000002 /* DocApprovalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DocApprovalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -38,6 +33,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A1000000000000000000000B /* DocApprovalCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,8 +61,6 @@ children = ( A20000000000000000000001 /* DocApprovalApp.swift */, A20000000000000000000002 /* AppSettings.swift */, - A20000000000000000000003 /* TraverseClient.swift */, - A20000000000000000000004 /* ExecutionViewModel.swift */, A20000000000000000000005 /* ContentView.swift */, A20000000000000000000006 /* SettingsView.swift */, A20000000000000000000007 /* Assets.xcassets */, @@ -78,8 +72,7 @@ A50000000000000000000003 /* DocApprovalTests */ = { isa = PBXGroup; children = ( - A20000000000000000000008 /* TraverseClientTests.swift */, - A20000000000000000000009 /* ExecutionViewModelTests.swift */, + A2000000000000000000000E /* AppSettingsSmokeTests.swift */, ); path = DocApprovalTests; sourceTree = ""; @@ -109,6 +102,9 @@ dependencies = ( ); name = DocApproval; + packageProductDependencies = ( + A1000000000000000000000C /* DocApprovalCore */, + ); productName = DocApproval; productReference = A30000000000000000000001 /* DocApproval.app */; productType = "com.apple.product-type.application"; @@ -159,6 +155,9 @@ Base, ); mainGroup = A50000000000000000000001; + packageReferences = ( + A1000000000000000000000D /* XCLocalSwiftPackageReference "../DocApprovalCore" */, + ); productRefGroup = A50000000000000000000004 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -194,8 +193,6 @@ files = ( A10000000000000000000001 /* DocApprovalApp.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 */, + A1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -376,6 +372,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A1000000000000000000000D /* XCLocalSwiftPackageReference "../DocApprovalCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../DocApprovalCore; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A1000000000000000000000C /* DocApprovalCore */ = { + isa = XCSwiftPackageProductDependency; + package = A1000000000000000000000D /* XCLocalSwiftPackageReference "../DocApprovalCore" */; + productName = DocApprovalCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = AA0000000000000000000001 /* Project object */; } diff --git a/apps/doc-approval/ios-swift/DocApproval/AppSettings.swift b/apps/doc-approval/ios-swift/DocApproval/AppSettings.swift index 00a75f0..797344c 100644 --- a/apps/doc-approval/ios-swift/DocApproval/AppSettings.swift +++ b/apps/doc-approval/ios-swift/DocApproval/AppSettings.swift @@ -1,8 +1,9 @@ import Foundation +import DocApprovalCore @MainActor final class AppSettings: ObservableObject { - static let capabilityId = "doc-approval.analyze" + static let appId = "doc-approval" static let documentMaxLength = 10_000 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 = SessionDiscovery.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/doc-approval/ios-swift/DocApproval/ContentView.swift b/apps/doc-approval/ios-swift/DocApproval/ContentView.swift index 5c02497..f9101c2 100644 --- a/apps/doc-approval/ios-swift/DocApproval/ContentView.swift +++ b/apps/doc-approval/ios-swift/DocApproval/ContentView.swift @@ -1,8 +1,9 @@ import SwiftUI +import DocApprovalCore 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,30 +81,36 @@ struct ContentView: View { private var outputSection: some View { GroupBox("Analysis Result") { 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 analysis output here.") .foregroundStyle(.secondary) - } else { + } else if viewModel.submitting { + Text("Submitting command…") + } else if viewModel.errorMessage == nil { Text("Submit a document above to start analysis.") .foregroundStyle(.secondary) } - case .loading: - Text("Starting analysis…") - case .polling(let executionId): - Text("Polling execution \(executionId)…") - .font(.footnote.monospaced()) - case .failed(let error): - Text("Error: \(error)") + case "processing": + Text("Analyzing…") + case "error": + Text("Error: \(viewModel.errorMessage ?? "execution failed")") .foregroundStyle(.red) - Button("Reset") { viewModel.reset() } + Button("Reset") { viewModel.resetLocal() } .buttonStyle(.bordered) - 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()) @@ -117,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) @@ -170,5 +180,9 @@ struct ContentView: View { let settings = AppSettings() ContentView() .environmentObject(settings) - .environmentObject(ExecutionViewModel(client: TraverseClient(), settings: settings)) + .environmentObject(AppStateViewModel( + client: DocApprovalClient(), + baseURL: settings.baseURL, + workspaceId: settings.workspace + )) } diff --git a/apps/doc-approval/ios-swift/DocApproval/DocApprovalApp.swift b/apps/doc-approval/ios-swift/DocApproval/DocApprovalApp.swift index a64382a..1665ad4 100644 --- a/apps/doc-approval/ios-swift/DocApproval/DocApprovalApp.swift +++ b/apps/doc-approval/ios-swift/DocApproval/DocApprovalApp.swift @@ -1,16 +1,20 @@ import SwiftUI +import DocApprovalCore @main struct DocApprovalApp: 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( - client: TraverseClient(), - settings: settings + _viewModel = StateObject(wrappedValue: AppStateViewModel( + client: DocApprovalClient(), + baseURL: settings.baseURL, + workspaceId: settings.workspace, + appId: AppSettings.appId, + documentMaxLength: AppSettings.documentMaxLength )) } @@ -19,6 +23,12 @@ struct DocApprovalApp: 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/doc-approval/ios-swift/DocApproval/ExecutionViewModel.swift b/apps/doc-approval/ios-swift/DocApproval/ExecutionViewModel.swift deleted file mode 100644 index e3d4ff9..0000000 --- a/apps/doc-approval/ios-swift/DocApproval/ExecutionViewModel.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation - -enum ExecutionPhase: Equatable { - case idle - case loading - case polling(executionId: String) - case succeeded(output: DocApprovalOutput, 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 document: 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 && - !document.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 trimmedDocument = document.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: ["document": trimmedDocument], - 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 - document = "" - 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 ?? DocApprovalOutput( - docType: "", - parties: [], - amounts: [], - confidence: 0, - recommendation: "" - ) - 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/doc-approval/ios-swift/DocApproval/TraverseClient.swift b/apps/doc-approval/ios-swift/DocApproval/TraverseClient.swift deleted file mode 100644 index 9656df3..0000000 --- a/apps/doc-approval/ios-swift/DocApproval/TraverseClient.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation - -struct DocApprovalOutput: Equatable, Codable { - let docType: String - let parties: [String] - let amounts: [String] - let confidence: Double - let recommendation: 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: DocApprovalOutput? - 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?) -> DocApprovalOutput? { - guard let dict = raw as? [String: Any], - let docType = dict["docType"] as? String, - let parties = dict["parties"] as? [String], - let amounts = dict["amounts"] as? [String], - let confidence = dict["confidence"] as? Double, - let recommendation = dict["recommendation"] as? String else { - return nil - } - return DocApprovalOutput( - docType: docType, - parties: parties, - amounts: amounts, - confidence: confidence, - recommendation: recommendation - ) - } -} - -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/doc-approval/ios-swift/DocApprovalTests/AppSettingsSmokeTests.swift b/apps/doc-approval/ios-swift/DocApprovalTests/AppSettingsSmokeTests.swift new file mode 100644 index 0000000..3c1d843 --- /dev/null +++ b/apps/doc-approval/ios-swift/DocApprovalTests/AppSettingsSmokeTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import DocApproval + +final class AppSettingsSmokeTests: XCTestCase { + @MainActor + func testDefaults() { + let defaults = UserDefaults(suiteName: "doc-approval-smoke-\(UUID().uuidString)")! + let settings = AppSettings(userDefaults: defaults) + XCTAssertEqual(settings.workspace, AppSettings.defaultWorkspace) + XCTAssertFalse(settings.baseURLString.isEmpty) + } +} diff --git a/apps/doc-approval/ios-swift/DocApprovalTests/ExecutionViewModelTests.swift b/apps/doc-approval/ios-swift/DocApprovalTests/ExecutionViewModelTests.swift deleted file mode 100644 index ba2ac30..0000000 --- a/apps/doc-approval/ios-swift/DocApprovalTests/ExecutionViewModelTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import XCTest -@testable import DocApproval - -@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 testCanSubmitWhenOnlineWithDocument() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.healthOK = true - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.document = "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: DocApprovalOutput( - docType: "invoice", - parties: ["Acme"], - amounts: ["$500"], - confidence: 0.88, - recommendation: "approve" - ), - error: nil - ), - ] - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.document = "invoice #123" - vm.submit() - try? await Task.sleep(nanoseconds: 2_000_000_000) - if case .succeeded(let output, _) = vm.phase { - XCTAssertEqual(output.docType, "invoice") - } 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.document = "doc" - 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) - XCTAssertEqual(vm.document, "") - } -} diff --git a/apps/doc-approval/ios-swift/DocApprovalTests/TraverseClientTests.swift b/apps/doc-approval/ios-swift/DocApprovalTests/TraverseClientTests.swift deleted file mode 100644 index fab949e..0000000 --- a/apps/doc-approval/ios-swift/DocApprovalTests/TraverseClientTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation -import XCTest -@testable import DocApproval - -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: "doc-approval.analyze", - input: ["document": "invoice text"], - 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":{"docType":"invoice","parties":["Acme"],"amounts":["$100"],"confidence":0.9,"recommendation":"approve"}} - """.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?.docType, "invoice") - XCTAssertEqual(result.output?.recommendation, "approve") - } -} diff --git a/apps/doc-approval/ios-swift/README.md b/apps/doc-approval/ios-swift/README.md index b791d74..7eae943 100644 --- a/apps/doc-approval/ios-swift/README.md +++ b/apps/doc-approval/ios-swift/README.md @@ -1,52 +1,28 @@ -# doc-approval (iOS SwiftUI) +# doc-approval (iOS) -Native iOS submitter client for the **doc-approval** reference app. Phase 1 uses HTTP polling against the public Traverse HTTP/JSON API (spec 033) — same flow as `web-react`. +Native iOS submitter client for the **doc-approval** reference app. Uses shared `DocApprovalCore` for HTTP command dispatch and SSE app-state events. ## Prerequisites -- **Xcode 16+** with iOS 17 simulator -- **Traverse runtime** running locally or on your LAN +- Xcode 16+ +- Traverse runtime locally (`cargo run -p traverse-cli -- serve`) -```bash -git clone https://github.com/traverse-framework/Traverse.git /tmp/traverse -cd /tmp/traverse && git checkout v0.6.0 -cargo run -p traverse-cli -- serve -``` - -The doc-approval capability must be registered in the runtime before execute calls succeed. - -## Runtime URL configuration - -1. Open the app in the simulator -2. Tap **Settings** -3. Set **Runtime URL** (default `http://127.0.0.1:8787`) and **Workspace** (`local-default`) -4. Save - -Settings persist in UserDefaults. For a physical device on Wi‑Fi, use your Mac's LAN IP instead of `127.0.0.1`. - -## Build and run +## Open and run ```bash -cd apps/doc-approval/ios-swift -xcodebuild -scheme DocApproval \ - -destination 'platform=iOS Simulator,name=iPhone 17' \ - build test +open apps/doc-approval/ios-swift/DocApproval.xcodeproj ``` -Or open `DocApproval.xcodeproj` in Xcode and run on an iOS 17+ simulator. +Settings (gear) configures runtime URL and workspace (defaults `http://127.0.0.1:8787` / `local-default`). ## Architecture -| File | Role | +| Path | 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, document input, analysis fields | -| `SettingsView.swift` | Runtime URL + workspace | -| `AppSettings.swift` | UserDefaults persistence | - -The UI renders runtime-provided analysis fields only — no business logic in the client. +| `../DocApprovalCore` | Shared HTTP/SSE client, `AppStateViewModel`, session listing | +| `ContentView.swift` | SwiftUI shell — input, output, runtime strip | +| `AppSettings.swift` | UserDefaults for URL / workspace | -## Phase 2 (not implemented) +## Design language -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, add approver surface, and share `DocApprovalCore` with macOS (see [#72](https://github.com/traverse-framework/reference-apps/issues/72)). +Follow [docs/design-language.md](../../../docs/design-language.md). diff --git a/apps/doc-approval/macos-swift/DocApprovalMac.xcodeproj/project.pbxproj b/apps/doc-approval/macos-swift/DocApprovalMac.xcodeproj/project.pbxproj index 7b91596..1f7cb3b 100644 --- a/apps/doc-approval/macos-swift/DocApprovalMac.xcodeproj/project.pbxproj +++ b/apps/doc-approval/macos-swift/DocApprovalMac.xcodeproj/project.pbxproj @@ -7,29 +7,24 @@ objects = { /* Begin PBXBuildFile section */ + B1000000000000000000000B /* DocApprovalCore in Frameworks */ = {isa = PBXBuildFile; productRef = B1000000000000000000000C /* DocApprovalCore */; }; + B1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* AppSettingsSmokeTests.swift */; }; B10000000000000000000001 /* DocApprovalMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* DocApprovalMacApp.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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + B2000000000000000000000E /* AppSettingsSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsSmokeTests.swift; sourceTree = ""; }; B20000000000000000000001 /* DocApprovalMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocApprovalMacApp.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 = ""; }; B30000000000000000000001 /* DocApprovalMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DocApprovalMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; B30000000000000000000002 /* DocApprovalMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DocApprovalMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -40,6 +35,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B1000000000000000000000B /* DocApprovalCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -68,8 +64,6 @@ B20000000000000000000001 /* DocApprovalMacApp.swift */, B20000000000000000000007 /* AppDelegate.swift */, B20000000000000000000002 /* AppSettings.swift */, - B20000000000000000000003 /* TraverseClient.swift */, - B20000000000000000000004 /* ExecutionViewModel.swift */, B20000000000000000000005 /* ContentView.swift */, B20000000000000000000006 /* PreferencesView.swift */, B20000000000000000000008 /* Assets.xcassets */, @@ -81,8 +75,7 @@ B50000000000000000000003 /* DocApprovalMacTests */ = { isa = PBXGroup; children = ( - B20000000000000000000009 /* TraverseClientTests.swift */, - B20000000000000000000010 /* ExecutionViewModelTests.swift */, + B2000000000000000000000E /* AppSettingsSmokeTests.swift */, ); path = DocApprovalMacTests; sourceTree = ""; @@ -112,6 +105,9 @@ dependencies = ( ); name = DocApprovalMac; + packageProductDependencies = ( + B1000000000000000000000C /* DocApprovalCore */, + ); productName = DocApprovalMac; productReference = B30000000000000000000001 /* DocApprovalMac.app */; productType = "com.apple.product-type.application"; @@ -162,6 +158,9 @@ Base, ); mainGroup = B50000000000000000000001; + packageReferences = ( + B1000000000000000000000D /* XCLocalSwiftPackageReference "../DocApprovalCore" */, + ); productRefGroup = B50000000000000000000004 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -198,8 +197,6 @@ B10000000000000000000001 /* DocApprovalMacApp.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 */, ); @@ -209,8 +206,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B10000000000000000000009 /* TraverseClientTests.swift in Sources */, - B10000000000000000000010 /* ExecutionViewModelTests.swift in Sources */, + B1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -375,6 +371,21 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + B1000000000000000000000D /* XCLocalSwiftPackageReference "../DocApprovalCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../DocApprovalCore; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B1000000000000000000000C /* DocApprovalCore */ = { + isa = XCSwiftPackageProductDependency; + package = B1000000000000000000000D /* XCLocalSwiftPackageReference "../DocApprovalCore" */; + productName = DocApprovalCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = BA0000000000000000000001 /* Project object */; } diff --git a/apps/doc-approval/macos-swift/DocApprovalMac/AppSettings.swift b/apps/doc-approval/macos-swift/DocApprovalMac/AppSettings.swift index 00a75f0..4ec0bd5 100644 --- a/apps/doc-approval/macos-swift/DocApprovalMac/AppSettings.swift +++ b/apps/doc-approval/macos-swift/DocApprovalMac/AppSettings.swift @@ -1,8 +1,9 @@ import Foundation +import DocApprovalCore @MainActor final class AppSettings: ObservableObject { - static let capabilityId = "doc-approval.analyze" + static let appId = "doc-approval" static let documentMaxLength = 10_000 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 = SessionDiscovery.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/doc-approval/macos-swift/DocApprovalMac/ContentView.swift b/apps/doc-approval/macos-swift/DocApprovalMac/ContentView.swift index ea005a1..a8e1185 100644 --- a/apps/doc-approval/macos-swift/DocApprovalMac/ContentView.swift +++ b/apps/doc-approval/macos-swift/DocApprovalMac/ContentView.swift @@ -1,8 +1,9 @@ import SwiftUI +import DocApprovalCore 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(viewModel.isRunning ? "Analyzing…" : "Analyze Document") { 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("Analysis Result") { 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 analysis output here.") .foregroundStyle(.secondary) - } else { + } else if viewModel.submitting { + Text("Submitting command…") + } else if viewModel.errorMessage == nil { Text("Submit a document above to start analysis (⌘↩).") .foregroundStyle(.secondary) } - case .loading: - Text("Starting analysis…") - case .polling(let executionId): - Text("Polling execution \(executionId)…") - .font(.footnote.monospaced()) - case .failed(let error): - Text("Error: \(error)") + case "processing": + Text("Analyzing…") + 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) @@ -168,5 +178,9 @@ struct ContentView: View { let settings = AppSettings() ContentView() .environmentObject(settings) - .environmentObject(ExecutionViewModel(client: TraverseClient(), settings: settings)) + .environmentObject(AppStateViewModel( + client: DocApprovalClient(), + baseURL: settings.baseURL, + workspaceId: settings.workspace + )) } diff --git a/apps/doc-approval/macos-swift/DocApprovalMac/DocApprovalMacApp.swift b/apps/doc-approval/macos-swift/DocApprovalMac/DocApprovalMacApp.swift index 544f776..6a20071 100644 --- a/apps/doc-approval/macos-swift/DocApprovalMac/DocApprovalMacApp.swift +++ b/apps/doc-approval/macos-swift/DocApprovalMac/DocApprovalMacApp.swift @@ -1,25 +1,26 @@ import SwiftUI +import DocApprovalCore -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("Document") { Button("Analyze Document") { 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 DocApprovalMacApp: 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( - client: TraverseClient(), - settings: settings + _viewModel = StateObject(wrappedValue: AppStateViewModel( + client: DocApprovalClient(), + baseURL: settings.baseURL, + workspaceId: settings.workspace, + appId: AppSettings.appId, + documentMaxLength: AppSettings.documentMaxLength )) } @@ -45,7 +49,13 @@ struct DocApprovalMacApp: 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/doc-approval/macos-swift/DocApprovalMac/ExecutionViewModel.swift b/apps/doc-approval/macos-swift/DocApprovalMac/ExecutionViewModel.swift deleted file mode 100644 index e3d4ff9..0000000 --- a/apps/doc-approval/macos-swift/DocApprovalMac/ExecutionViewModel.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation - -enum ExecutionPhase: Equatable { - case idle - case loading - case polling(executionId: String) - case succeeded(output: DocApprovalOutput, 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 document: 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 && - !document.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 trimmedDocument = document.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: ["document": trimmedDocument], - 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 - document = "" - 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 ?? DocApprovalOutput( - docType: "", - parties: [], - amounts: [], - confidence: 0, - recommendation: "" - ) - 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/doc-approval/macos-swift/DocApprovalMac/TraverseClient.swift b/apps/doc-approval/macos-swift/DocApprovalMac/TraverseClient.swift deleted file mode 100644 index 9656df3..0000000 --- a/apps/doc-approval/macos-swift/DocApprovalMac/TraverseClient.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation - -struct DocApprovalOutput: Equatable, Codable { - let docType: String - let parties: [String] - let amounts: [String] - let confidence: Double - let recommendation: 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: DocApprovalOutput? - 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?) -> DocApprovalOutput? { - guard let dict = raw as? [String: Any], - let docType = dict["docType"] as? String, - let parties = dict["parties"] as? [String], - let amounts = dict["amounts"] as? [String], - let confidence = dict["confidence"] as? Double, - let recommendation = dict["recommendation"] as? String else { - return nil - } - return DocApprovalOutput( - docType: docType, - parties: parties, - amounts: amounts, - confidence: confidence, - recommendation: recommendation - ) - } -} - -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/doc-approval/macos-swift/DocApprovalMacTests/AppSettingsSmokeTests.swift b/apps/doc-approval/macos-swift/DocApprovalMacTests/AppSettingsSmokeTests.swift new file mode 100644 index 0000000..7cc2319 --- /dev/null +++ b/apps/doc-approval/macos-swift/DocApprovalMacTests/AppSettingsSmokeTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import DocApprovalMac + +final class AppSettingsSmokeTests: XCTestCase { + @MainActor + func testDefaults() { + let defaults = UserDefaults(suiteName: "doc-approval-mac-smoke-\(UUID().uuidString)")! + let settings = AppSettings(userDefaults: defaults) + XCTAssertEqual(settings.workspace, AppSettings.defaultWorkspace) + XCTAssertFalse(settings.baseURLString.isEmpty) + } +} diff --git a/apps/doc-approval/macos-swift/DocApprovalMacTests/ExecutionViewModelTests.swift b/apps/doc-approval/macos-swift/DocApprovalMacTests/ExecutionViewModelTests.swift deleted file mode 100644 index 0fe58d3..0000000 --- a/apps/doc-approval/macos-swift/DocApprovalMacTests/ExecutionViewModelTests.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import XCTest -@testable import DocApprovalMac - -@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 testCanSubmitWhenOnlineWithDocument() async { - let settings = AppSettings() - let client = MockTraverseClient() - client.healthOK = true - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.document = "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: DocApprovalOutput( - docType: "invoice", - parties: ["Acme"], - amounts: ["$500"], - confidence: 0.88, - recommendation: "approve" - ), - error: nil - ), - ] - let vm = ExecutionViewModel(client: client, settings: settings) - await vm.refreshHealth() - vm.document = "invoice #123" - vm.submit() - try? await Task.sleep(nanoseconds: 2_000_000_000) - if case .succeeded(let output, _) = vm.phase { - XCTAssertEqual(output.docType, "invoice") - } 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.document = "doc" - 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) - XCTAssertEqual(vm.document, "") - } -} diff --git a/apps/doc-approval/macos-swift/DocApprovalMacTests/TraverseClientTests.swift b/apps/doc-approval/macos-swift/DocApprovalMacTests/TraverseClientTests.swift deleted file mode 100644 index e8f4829..0000000 --- a/apps/doc-approval/macos-swift/DocApprovalMacTests/TraverseClientTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation -import XCTest -@testable import DocApprovalMac - -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: "doc-approval.analyze", - input: ["document": "invoice text"], - 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":{"docType":"invoice","parties":["Acme"],"amounts":["$100"],"confidence":0.9,"recommendation":"approve"}} - """.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?.docType, "invoice") - XCTAssertEqual(result.output?.recommendation, "approve") - } -} diff --git a/apps/doc-approval/macos-swift/README.md b/apps/doc-approval/macos-swift/README.md index 1a4adda..e84e9ce 100644 --- a/apps/doc-approval/macos-swift/README.md +++ b/apps/doc-approval/macos-swift/README.md @@ -1,45 +1,28 @@ -# doc-approval (macOS SwiftUI) +# doc-approval (macOS) -Native macOS submitter client for the **doc-approval** 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 submitter client for the **doc-approval** reference app. Uses shared `DocApprovalCore` for HTTP command dispatch and SSE app-state events. ## Prerequisites -- **Xcode 16+** on macOS 14+ -- **Traverse runtime** running locally +- Xcode 16+ +- Traverse runtime locally (`cargo run -p traverse-cli -- serve`) -```bash -git clone https://github.com/traverse-framework/Traverse.git /tmp/traverse -cd /tmp/traverse && git checkout v0.6.0 -cargo run -p traverse-cli -- serve -``` - -The doc-approval capability must be registered in the runtime before execute calls succeed. - -## Runtime URL configuration - -Use **DocApprovalMac → Settings** (⌘,) to set **Runtime URL** (default `http://127.0.0.1:8787`) and **Workspace** (`local-default`). Settings persist in UserDefaults. - -## Build and run +## Open and run ```bash -cd apps/doc-approval/macos-swift -xcodebuild -scheme DocApprovalMac -destination 'platform=macOS' build test +open apps/doc-approval/macos-swift/DocApprovalMac.xcodeproj ``` -Or open `DocApprovalMac.xcodeproj` in Xcode. +Preferences (⌘,) configures runtime URL and workspace. On first launch, macOS may discover `.traverse/server.json` via `SessionDiscovery`. ## Architecture -| File | Role | +| Path | Role | |---|---| -| `TraverseClient.swift` | URLSession HTTP client (execute, poll, trace, health) | -| `ExecutionViewModel.swift` | MVVM state machine: idle → loading → polling → done | -| `ContentView.swift` | Split view — sidebar, document input, analysis output | -| `PreferencesView.swift` | Runtime URL + workspace (Settings window) | -| `AppSettings.swift` | UserDefaults persistence | - -The UI renders runtime-provided analysis fields only — no business logic in the client. +| `../DocApprovalCore` | Shared HTTP/SSE client, `AppStateViewModel`, session listing | +| `ContentView.swift` | SwiftUI shell — sidebar + document/output | +| `AppSettings.swift` | UserDefaults for URL / workspace | -## Phase 2 (not implemented) +## Design language -When Traverse ships SSE and session listing ([#525](https://github.com/traverse-framework/Traverse/issues/525)–[#527](https://github.com/traverse-framework/Traverse/issues/527), [#537](https://github.com/traverse-framework/Traverse/issues/537)), replace polling with SSE, add approver surface, and share `DocApprovalCore` with ios-swift (see [#72](https://github.com/traverse-framework/reference-apps/issues/72)). +Follow [docs/design-language.md](../../../docs/design-language.md). diff --git a/scripts/ci/repository_checks.sh b/scripts/ci/repository_checks.sh index 3cf70f5..0d6e010 100644 --- a/scripts/ci/repository_checks.sh +++ b/scripts/ci/repository_checks.sh @@ -60,6 +60,7 @@ check "apps/doc-approval/linux-gtk/Cargo.toml" "doc-a check "apps/doc-approval/linux-gtk/README.md" "doc-approval linux-gtk README" check "apps/doc-approval/cli-rust/Cargo.toml" "doc-approval cli-rust Cargo project" check "apps/doc-approval/cli-rust/README.md" "doc-approval cli-rust README" +check "apps/doc-approval/DocApprovalCore/Package.swift" "doc-approval DocApprovalCore package" # meeting-notes clients check "apps/meeting-notes/web-react/package.json" "meeting-notes web-react package"