Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ coverage/
# Traverse checkout symlink for Phase 2 manifest validation
manifests/traverse-starter/_traverse
.traverse/
apps/traverse-starter/TraverseCore/.build/
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \

- **Phase 3 embedded runtime** ([#109](https://github.com/traverse-framework/reference-apps/issues/109)–[#118](https://github.com/traverse-framework/reference-apps/issues/118)) — see `docs/embedded-runtime-plan.md`; blocked on a **consumable platform embedder SDK** (Traverse [#553](https://github.com/traverse-framework/Traverse/issues/553) closed via [#578](https://github.com/traverse-framework/Traverse/pull/578) with manifest `execution_mode` only — no web/Swift/etc. embedder package yet). Traverse [#615](https://github.com/traverse-framework/Traverse/pull/615) added wasm32 core build only — not a platform embedder package.
- **doc-approval multi-capability showcase** ([#111](https://github.com/traverse-framework/reference-apps/issues/111), [#112](https://github.com/traverse-framework/reference-apps/issues/112)) — blocked on Traverse [#538](https://github.com/traverse-framework/Traverse/issues/538) / [#555](https://github.com/traverse-framework/Traverse/issues/555) (`extract` / `recommend` agents)
- **traverse-starter.pipeline showcase** ([#110](https://github.com/traverse-framework/reference-apps/issues/110)) — blocked on Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) (pipeline execute surface from #600 never landed on `main`; validate/summarize agents are on `main`)

Ready: [#58](https://github.com/traverse-framework/reference-apps/issues/58)/[#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) — HTTP/SSE shared client extraction (Traverse #525–#527 + platform scaffolds done; [#43](https://github.com/traverse-framework/reference-apps/issues/43) web SSE shipped).
Ready: [#110](https://github.com/traverse-framework/reference-apps/issues/110) traverse-starter.pipeline (Traverse [#620](https://github.com/traverse-framework/Traverse/issues/620) closed); [#59](https://github.com/traverse-framework/reference-apps/issues/59)/[#72](https://github.com/traverse-framework/reference-apps/issues/72)/[#73](https://github.com/traverse-framework/reference-apps/issues/73) shared client packages ([#58](https://github.com/traverse-framework/reference-apps/issues/58) in progress).

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

Expand Down
1 change: 1 addition & 0 deletions apps/traverse-starter/TraverseCore/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.build/
20 changes: 20 additions & 0 deletions apps/traverse-starter/TraverseCore/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "TraverseCore",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "TraverseCore", targets: ["TraverseCore"]),
],
targets: [
.target(name: "TraverseCore"),
.testTarget(
name: "TraverseCoreTests",
dependencies: ["TraverseCore"]
),
]
)
15 changes: 15 additions & 0 deletions apps/traverse-starter/TraverseCore/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# TraverseCore

Shared Swift package for traverse-starter **iOS** and **macOS** clients.

- `TraverseClient` — health, `sendCommand`, SSE `subscribeAppEvents`, fetchTrace
- `AppStateViewModel` — `@MainActor ObservableObject` mapping runtime SSE payloads
- `TraverseOutput` / `TraverseCommand` — public types + parsers
- `ServerDiscovery` — reads `.traverse/server.json` on macOS only

```bash
cd apps/traverse-starter/TraverseCore
swift test
```

No UIKit / AppKit imports. Platforms: iOS 17+, macOS 14+.
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import Combine
import Foundation

public enum RuntimeStatus: Equatable, Sendable {
case checking
case online
case offline
}

/// Subscribes to runtime app SSE and maps event payloads to render state.
/// Contains no local transition logic beyond applying runtime-owned fields.
@MainActor
public final class AppStateViewModel: ObservableObject {
@Published public private(set) var currentState: String = "idle"
@Published public private(set) var output: TraverseStarterOutput?
@Published public private(set) var errorMessage: String?
@Published public private(set) var connected: Bool = false
@Published public private(set) var sessionId: String?
@Published public private(set) var executionId: String?
@Published public private(set) var trace: [TraceEvent] = []
@Published public private(set) var runtimeStatus: RuntimeStatus = .checking
@Published public private(set) var submitting: Bool = false
@Published public var note: String = ""
@Published public var showTrace: Bool = false

public let appId: String
public let noteMaxLength: Int

private let client: TraverseClientProtocol
private var baseURL: URL?
private var workspaceId: String
private var sseTask: Task<Void, Never>?
private var healthTask: Task<Void, Never>?

public init(
client: TraverseClientProtocol,
baseURL: URL?,
workspaceId: String,
appId: String = "traverse-starter",
noteMaxLength: Int = 2000
) {
self.client = client
self.baseURL = baseURL
self.workspaceId = workspaceId
self.appId = appId
self.noteMaxLength = noteMaxLength
startHealthChecks()
startSSE()
}

deinit {
sseTask?.cancel()
healthTask?.cancel()
}

public var canSubmit: Bool {
runtimeStatus == .online &&
!note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!isRunning
}

public var isRunning: Bool {
submitting || currentState == "processing"
}

public func updateConnection(baseURL: URL?, workspaceId: String) {
let changed = self.baseURL != baseURL || self.workspaceId != workspaceId
self.baseURL = baseURL
self.workspaceId = workspaceId
if changed {
startSSE()
Task { await refreshHealth() }
}
}

public func startHealthChecks() {
healthTask?.cancel()
healthTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refreshHealth()
try? await Task.sleep(nanoseconds: 5_000_000_000)
}
}
}

public func refreshHealth() async {
guard let baseURL else {
runtimeStatus = .offline
return
}
do {
let ok = try await client.checkHealth(baseURL: baseURL)
runtimeStatus = ok ? .online : .offline
} catch {
runtimeStatus = .offline
}
}

public func submit() {
guard canSubmit, let baseURL else { return }
let trimmed = String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(noteMaxLength))
submitting = true
errorMessage = nil
trace = []
Task { [weak self] in
guard let self else { return }
do {
// New session each submit (omit session_id) so submit is valid from idle.
_ = try await client.sendCommand(
workspaceId: workspaceId,
appId: appId,
command: .submit(note: trimmed),
baseURL: baseURL
)
} catch {
await MainActor.run {
self.errorMessage = String(describing: error)
self.submitting = false
}
return
}
await MainActor.run { self.submitting = false }
}
}

public func resetLocal() {
currentState = "idle"
output = nil
errorMessage = nil
executionId = nil
sessionId = nil
trace = []
showTrace = false
submitting = false
}

private func startSSE() {
sseTask?.cancel()
connected = false
guard let baseURL else { return }
let workspaceId = self.workspaceId
let appId = self.appId
sseTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
do {
try await client.subscribeAppEvents(
workspaceId: workspaceId,
appId: appId,
baseURL: baseURL
) { [weak self] type, payload in
Task { @MainActor in
self?.apply(eventType: type, payload: payload)
}
}
} catch {
await MainActor.run { self.connected = false }
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
}

func apply(eventType: String, payload: AppStateEventPayload) {
if eventType == "heartbeat" {
connected = true
return
}
connected = true
if let state = payload.state, !state.isEmpty {
currentState = state
}
if let sessionId = payload.sessionId {
self.sessionId = sessionId
}
if let executionId = payload.executionId {
self.executionId = executionId
}
if let output = payload.output {
self.output = output
}
if let errorMessage = payload.errorMessage {
self.errorMessage = errorMessage
} else if payload.state == "results" {
self.errorMessage = nil
}
if currentState == "results", let executionId, let baseURL {
Task { [weak self] in
guard let self else { return }
let events = (try? await client.fetchTrace(
workspaceId: workspaceId,
executionId: executionId,
baseURL: baseURL
)) ?? []
await MainActor.run { self.trace = events }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

/// Reads `.traverse/server.json` when available (macOS / desktop). Returns nil on iOS.
public enum ServerDiscovery {
public struct ServerInfo: Equatable, Sendable {
public let baseURL: URL
public let workspaceDefault: String

public init(baseURL: URL, workspaceDefault: String) {
self.baseURL = baseURL
self.workspaceDefault = workspaceDefault
}
}

public static func discover(startingAt directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)) -> ServerInfo? {
#if os(macOS)
var current = directory
for _ in 0..<8 {
let candidate = current.appendingPathComponent(".traverse/server.json")
if let info = read(at: candidate) {
return info
}
let parent = current.deletingLastPathComponent()
if parent.path == current.path { break }
current = parent
}
return nil
#else
return nil
#endif
}

public static func read(at fileURL: URL) -> ServerInfo? {
#if os(macOS)
guard let data = try? Data(contentsOf: fileURL),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let base = json["base_url"] as? String,
let url = URL(string: base) else {
return nil
}
let workspace = (json["workspace_default"] as? String) ?? "local-default"
return ServerInfo(baseURL: url, workspaceDefault: workspace)
#else
return nil
#endif
}
}
Loading
Loading