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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions apps/doc-approval/DocApprovalCore/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.build/
20 changes: 20 additions & 0 deletions apps/doc-approval/DocApprovalCore/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: "DocApprovalCore",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "DocApprovalCore", targets: ["DocApprovalCore"]),
],
targets: [
.target(name: "DocApprovalCore"),
.testTarget(
name: "DocApprovalCoreTests",
dependencies: ["DocApprovalCore"]
),
]
)
8 changes: 8 additions & 0 deletions apps/doc-approval/DocApprovalCore/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?
private var healthTask: Task<Void, Never>?

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 }
}
}
}
}
Loading
Loading