Skip to content

Commit 6e50572

Browse files
Extract TraverseCore Swift package for iOS and macOS (#140)
Share SSE + sendCommand client and AppStateViewModel between Apple shells so both platforms follow the web-react runtime subscription pattern. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 08d7e51 commit 6e50572

31 files changed

Lines changed: 1075 additions & 1158 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ coverage/
1717
# Traverse checkout symlink for Phase 2 manifest validation
1818
manifests/traverse-starter/_traverse
1919
.traverse/
20+
apps/traverse-starter/TraverseCore/.build/

AGENTS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \
1313

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)
16-
- **traverse-starter.pipeline showcase** ([#110](https://github.com/traverse-framework/reference-apps/issues/110)) — blocked on Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) (pipeline execute surface from #600 never landed on `main`; validate/summarize agents are on `main`)
1716

18-
Ready: [#58](https://github.com/traverse-framework/reference-apps/issues/58)/[#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) — HTTP/SSE shared client extraction (Traverse #525#527 + platform scaffolds done; [#43](https://github.com/traverse-framework/reference-apps/issues/43) web SSE shipped).
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); [#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) shared client packages ([#58](https://github.com/traverse-framework/reference-apps/issues/58) in progress).
1918

2019
Update this section when a PR changes platform status (see PR template checklist).
2120

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: "TraverseCore",
6+
platforms: [
7+
.iOS(.v17),
8+
.macOS(.v14),
9+
],
10+
products: [
11+
.library(name: "TraverseCore", targets: ["TraverseCore"]),
12+
],
13+
targets: [
14+
.target(name: "TraverseCore"),
15+
.testTarget(
16+
name: "TraverseCoreTests",
17+
dependencies: ["TraverseCore"]
18+
),
19+
]
20+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# TraverseCore
2+
3+
Shared Swift package for traverse-starter **iOS** and **macOS** clients.
4+
5+
- `TraverseClient` — health, `sendCommand`, SSE `subscribeAppEvents`, fetchTrace
6+
- `AppStateViewModel``@MainActor ObservableObject` mapping runtime SSE payloads
7+
- `TraverseOutput` / `TraverseCommand` — public types + parsers
8+
- `ServerDiscovery` — reads `.traverse/server.json` on macOS only
9+
10+
```bash
11+
cd apps/traverse-starter/TraverseCore
12+
swift test
13+
```
14+
15+
No UIKit / AppKit imports. Platforms: iOS 17+, macOS 14+.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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+
/// Contains no local transition logic beyond applying runtime-owned fields.
12+
@MainActor
13+
public final class AppStateViewModel: ObservableObject {
14+
@Published public private(set) var currentState: String = "idle"
15+
@Published public private(set) var output: TraverseStarterOutput?
16+
@Published public private(set) var errorMessage: String?
17+
@Published public private(set) var connected: Bool = false
18+
@Published public private(set) var sessionId: String?
19+
@Published public private(set) var executionId: String?
20+
@Published public private(set) var trace: [TraceEvent] = []
21+
@Published public private(set) var runtimeStatus: RuntimeStatus = .checking
22+
@Published public private(set) var submitting: Bool = false
23+
@Published public var note: String = ""
24+
@Published public var showTrace: Bool = false
25+
26+
public let appId: String
27+
public let noteMaxLength: Int
28+
29+
private let client: TraverseClientProtocol
30+
private var baseURL: URL?
31+
private var workspaceId: String
32+
private var sseTask: Task<Void, Never>?
33+
private var healthTask: Task<Void, Never>?
34+
35+
public init(
36+
client: TraverseClientProtocol,
37+
baseURL: URL?,
38+
workspaceId: String,
39+
appId: String = "traverse-starter",
40+
noteMaxLength: Int = 2000
41+
) {
42+
self.client = client
43+
self.baseURL = baseURL
44+
self.workspaceId = workspaceId
45+
self.appId = appId
46+
self.noteMaxLength = noteMaxLength
47+
startHealthChecks()
48+
startSSE()
49+
}
50+
51+
deinit {
52+
sseTask?.cancel()
53+
healthTask?.cancel()
54+
}
55+
56+
public var canSubmit: Bool {
57+
runtimeStatus == .online &&
58+
!note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
59+
!isRunning
60+
}
61+
62+
public var isRunning: Bool {
63+
submitting || currentState == "processing"
64+
}
65+
66+
public func updateConnection(baseURL: URL?, workspaceId: String) {
67+
let changed = self.baseURL != baseURL || self.workspaceId != workspaceId
68+
self.baseURL = baseURL
69+
self.workspaceId = workspaceId
70+
if changed {
71+
startSSE()
72+
Task { await refreshHealth() }
73+
}
74+
}
75+
76+
public func startHealthChecks() {
77+
healthTask?.cancel()
78+
healthTask = Task { [weak self] in
79+
while !Task.isCancelled {
80+
await self?.refreshHealth()
81+
try? await Task.sleep(nanoseconds: 5_000_000_000)
82+
}
83+
}
84+
}
85+
86+
public func refreshHealth() async {
87+
guard let baseURL else {
88+
runtimeStatus = .offline
89+
return
90+
}
91+
do {
92+
let ok = try await client.checkHealth(baseURL: baseURL)
93+
runtimeStatus = ok ? .online : .offline
94+
} catch {
95+
runtimeStatus = .offline
96+
}
97+
}
98+
99+
public func submit() {
100+
guard canSubmit, let baseURL else { return }
101+
let trimmed = String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(noteMaxLength))
102+
submitting = true
103+
errorMessage = nil
104+
trace = []
105+
Task { [weak self] in
106+
guard let self else { return }
107+
do {
108+
// New session each submit (omit session_id) so submit is valid from idle.
109+
_ = try await client.sendCommand(
110+
workspaceId: workspaceId,
111+
appId: appId,
112+
command: .submit(note: trimmed),
113+
baseURL: baseURL
114+
)
115+
} catch {
116+
await MainActor.run {
117+
self.errorMessage = String(describing: error)
118+
self.submitting = false
119+
}
120+
return
121+
}
122+
await MainActor.run { self.submitting = false }
123+
}
124+
}
125+
126+
public func resetLocal() {
127+
currentState = "idle"
128+
output = nil
129+
errorMessage = nil
130+
executionId = nil
131+
sessionId = nil
132+
trace = []
133+
showTrace = false
134+
submitting = false
135+
}
136+
137+
private func startSSE() {
138+
sseTask?.cancel()
139+
connected = false
140+
guard let baseURL else { return }
141+
let workspaceId = self.workspaceId
142+
let appId = self.appId
143+
sseTask = Task { [weak self] in
144+
guard let self else { return }
145+
while !Task.isCancelled {
146+
do {
147+
try await client.subscribeAppEvents(
148+
workspaceId: workspaceId,
149+
appId: appId,
150+
baseURL: baseURL
151+
) { [weak self] type, payload in
152+
Task { @MainActor in
153+
self?.apply(eventType: type, payload: payload)
154+
}
155+
}
156+
} catch {
157+
await MainActor.run { self.connected = false }
158+
}
159+
try? await Task.sleep(nanoseconds: 2_000_000_000)
160+
}
161+
}
162+
}
163+
164+
func apply(eventType: String, payload: AppStateEventPayload) {
165+
if eventType == "heartbeat" {
166+
connected = true
167+
return
168+
}
169+
connected = true
170+
if let state = payload.state, !state.isEmpty {
171+
currentState = state
172+
}
173+
if let sessionId = payload.sessionId {
174+
self.sessionId = sessionId
175+
}
176+
if let executionId = payload.executionId {
177+
self.executionId = executionId
178+
}
179+
if let output = payload.output {
180+
self.output = output
181+
}
182+
if let errorMessage = payload.errorMessage {
183+
self.errorMessage = errorMessage
184+
} else if payload.state == "results" {
185+
self.errorMessage = nil
186+
}
187+
if currentState == "results", let executionId, let baseURL {
188+
Task { [weak self] in
189+
guard let self else { return }
190+
let events = (try? await client.fetchTrace(
191+
workspaceId: workspaceId,
192+
executionId: executionId,
193+
baseURL: baseURL
194+
)) ?? []
195+
await MainActor.run { self.trace = events }
196+
}
197+
}
198+
}
199+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
3+
/// Reads `.traverse/server.json` when available (macOS / desktop). Returns nil on iOS.
4+
public enum ServerDiscovery {
5+
public struct ServerInfo: Equatable, Sendable {
6+
public let baseURL: URL
7+
public let workspaceDefault: String
8+
9+
public init(baseURL: URL, workspaceDefault: String) {
10+
self.baseURL = baseURL
11+
self.workspaceDefault = workspaceDefault
12+
}
13+
}
14+
15+
public static func discover(startingAt directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)) -> ServerInfo? {
16+
#if os(macOS)
17+
var current = directory
18+
for _ in 0..<8 {
19+
let candidate = current.appendingPathComponent(".traverse/server.json")
20+
if let info = read(at: candidate) {
21+
return info
22+
}
23+
let parent = current.deletingLastPathComponent()
24+
if parent.path == current.path { break }
25+
current = parent
26+
}
27+
return nil
28+
#else
29+
return nil
30+
#endif
31+
}
32+
33+
public static func read(at fileURL: URL) -> ServerInfo? {
34+
#if os(macOS)
35+
guard let data = try? Data(contentsOf: fileURL),
36+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
37+
let base = json["base_url"] as? String,
38+
let url = URL(string: base) else {
39+
return nil
40+
}
41+
let workspace = (json["workspace_default"] as? String) ?? "local-default"
42+
return ServerInfo(baseURL: url, workspaceDefault: workspace)
43+
#else
44+
return nil
45+
#endif
46+
}
47+
}

0 commit comments

Comments
 (0)