Skip to content

Commit 67e701c

Browse files
Extract DocApprovalCore Swift package for iOS and macOS (#142)
* Extract DocApprovalCore Swift package for iOS and macOS Shared HTTP/SSE client, session listing, and AppStateViewModel for doc-approval Apple shells, mirroring TraverseCore from #58. Co-authored-by: Cursor <cursoragent@cursor.com> * Update doc-approval Apple READMEs for DocApprovalCore Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 77aad49 commit 67e701c

33 files changed

Lines changed: 1344 additions & 1182 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \
1414
- **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.
1515
- **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)
1616

17-
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).
17+
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).
1818

1919
Update this section when a PR changes platform status (see PR template checklist).
2020

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,4 @@ Active blockers on [Project 2](https://github.com/orgs/traverse-framework/projec
175175
- **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).
176176
- **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`).
177177

178-
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).
178+
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).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.build/
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// swift-tools-version: 6.0
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "DocApprovalCore",
6+
platforms: [
7+
.iOS(.v17),
8+
.macOS(.v14),
9+
],
10+
products: [
11+
.library(name: "DocApprovalCore", targets: ["DocApprovalCore"]),
12+
],
13+
targets: [
14+
.target(name: "DocApprovalCore"),
15+
.testTarget(
16+
name: "DocApprovalCoreTests",
17+
dependencies: ["DocApprovalCore"]
18+
),
19+
]
20+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# DocApprovalCore
2+
3+
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.
4+
5+
```bash
6+
cd apps/doc-approval/DocApprovalCore
7+
swift test
8+
```
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import Combine
2+
import Foundation
3+
4+
public enum RuntimeStatus: Equatable, Sendable {
5+
case checking
6+
case online
7+
case offline
8+
}
9+
10+
/// Subscribes to runtime app SSE and maps event payloads to render state.
11+
@MainActor
12+
public final class AppStateViewModel: ObservableObject {
13+
@Published public private(set) var currentState: String = "idle"
14+
@Published public private(set) var output: DocApprovalOutput?
15+
@Published public private(set) var errorMessage: String?
16+
@Published public private(set) var connected: Bool = false
17+
@Published public private(set) var sessionId: String?
18+
@Published public private(set) var executionId: String?
19+
@Published public private(set) var trace: [TraceEvent] = []
20+
@Published public private(set) var runtimeStatus: RuntimeStatus = .checking
21+
@Published public private(set) var submitting: Bool = false
22+
@Published public var document: String = ""
23+
@Published public var showTrace: Bool = false
24+
25+
public let appId: String
26+
public let documentMaxLength: Int
27+
28+
private let client: DocApprovalClientProtocol
29+
private var baseURL: URL?
30+
private var workspaceId: String
31+
private var sseTask: Task<Void, Never>?
32+
private var healthTask: Task<Void, Never>?
33+
34+
public init(
35+
client: DocApprovalClientProtocol,
36+
baseURL: URL?,
37+
workspaceId: String,
38+
appId: String = "doc-approval",
39+
documentMaxLength: Int = 10_000
40+
) {
41+
self.client = client
42+
self.baseURL = baseURL
43+
self.workspaceId = workspaceId
44+
self.appId = appId
45+
self.documentMaxLength = documentMaxLength
46+
startHealthChecks()
47+
startSSE()
48+
}
49+
50+
deinit {
51+
sseTask?.cancel()
52+
healthTask?.cancel()
53+
}
54+
55+
public var canSubmit: Bool {
56+
runtimeStatus == .online &&
57+
!document.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
58+
!isRunning
59+
}
60+
61+
public var isRunning: Bool {
62+
submitting || currentState == "processing"
63+
}
64+
65+
public func updateConnection(baseURL: URL?, workspaceId: String) {
66+
let changed = self.baseURL != baseURL || self.workspaceId != workspaceId
67+
self.baseURL = baseURL
68+
self.workspaceId = workspaceId
69+
if changed {
70+
startSSE()
71+
Task { await refreshHealth() }
72+
}
73+
}
74+
75+
public func startHealthChecks() {
76+
healthTask?.cancel()
77+
healthTask = Task { [weak self] in
78+
while !Task.isCancelled {
79+
await self?.refreshHealth()
80+
try? await Task.sleep(nanoseconds: 5_000_000_000)
81+
}
82+
}
83+
}
84+
85+
public func refreshHealth() async {
86+
guard let baseURL else {
87+
runtimeStatus = .offline
88+
return
89+
}
90+
do {
91+
let ok = try await client.checkHealth(baseURL: baseURL)
92+
runtimeStatus = ok ? .online : .offline
93+
} catch {
94+
runtimeStatus = .offline
95+
}
96+
}
97+
98+
public func submit() {
99+
guard canSubmit, let baseURL else { return }
100+
let trimmed = String(document.trimmingCharacters(in: .whitespacesAndNewlines).prefix(documentMaxLength))
101+
submitting = true
102+
errorMessage = nil
103+
trace = []
104+
Task { [weak self] in
105+
guard let self else { return }
106+
do {
107+
_ = try await client.sendCommand(
108+
workspaceId: workspaceId,
109+
appId: appId,
110+
command: .submit(document: trimmed),
111+
baseURL: baseURL
112+
)
113+
} catch {
114+
await MainActor.run {
115+
self.errorMessage = String(describing: error)
116+
self.submitting = false
117+
}
118+
return
119+
}
120+
await MainActor.run { self.submitting = false }
121+
}
122+
}
123+
124+
public func resetLocal() {
125+
currentState = "idle"
126+
output = nil
127+
errorMessage = nil
128+
executionId = nil
129+
sessionId = nil
130+
trace = []
131+
showTrace = false
132+
submitting = false
133+
}
134+
135+
private func startSSE() {
136+
sseTask?.cancel()
137+
connected = false
138+
guard let baseURL else { return }
139+
let workspaceId = self.workspaceId
140+
let appId = self.appId
141+
sseTask = Task { [weak self] in
142+
guard let self else { return }
143+
while !Task.isCancelled {
144+
do {
145+
try await client.subscribeAppEvents(
146+
workspaceId: workspaceId,
147+
appId: appId,
148+
baseURL: baseURL
149+
) { [weak self] type, payload in
150+
Task { @MainActor in
151+
self?.apply(eventType: type, payload: payload)
152+
}
153+
}
154+
} catch {
155+
await MainActor.run { self.connected = false }
156+
}
157+
try? await Task.sleep(nanoseconds: 2_000_000_000)
158+
}
159+
}
160+
}
161+
162+
func apply(eventType: String, payload: AppStateEventPayload) {
163+
if eventType == "heartbeat" {
164+
connected = true
165+
return
166+
}
167+
connected = true
168+
if let state = payload.state, !state.isEmpty {
169+
currentState = state
170+
}
171+
if let sessionId = payload.sessionId {
172+
self.sessionId = sessionId
173+
}
174+
if let executionId = payload.executionId {
175+
self.executionId = executionId
176+
}
177+
if let output = payload.output {
178+
self.output = output
179+
}
180+
if let errorMessage = payload.errorMessage {
181+
self.errorMessage = errorMessage
182+
} else if payload.state == "results" {
183+
self.errorMessage = nil
184+
}
185+
if currentState == "results", let executionId, let baseURL {
186+
Task { [weak self] in
187+
guard let self else { return }
188+
let events = (try? await client.fetchTrace(
189+
workspaceId: workspaceId,
190+
executionId: executionId,
191+
baseURL: baseURL
192+
)) ?? []
193+
await MainActor.run { self.trace = events }
194+
}
195+
}
196+
}
197+
}

0 commit comments

Comments
 (0)