diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7266368..76d7be0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -74,6 +74,15 @@ jobs: build test \ CODE_SIGNING_ALLOWED=NO + - name: Build + test meeting-notes macOS + working-directory: apps/meeting-notes/macos-swift + run: | + xcodebuild \ + -scheme MeetingNotesMac \ + -destination 'platform=macOS' \ + build test \ + CODE_SIGNING_ALLOWED=NO + native-windows: runs-on: windows-latest steps: @@ -100,6 +109,12 @@ jobs: dotnet restore DocApproval.Tests/DocApproval.Tests.csproj dotnet test DocApproval.Tests/DocApproval.Tests.csproj -c Release --nologo + - name: Restore + test meeting-notes WinUI logic + working-directory: apps/meeting-notes/windows-winui + run: | + dotnet restore MeetingNotes.Tests/MeetingNotes.Tests.csproj + dotnet test MeetingNotes.Tests/MeetingNotes.Tests.csproj -c Release --nologo + # Required: GTK shells (AdwApplicationWindowExt / GString fixes landed). native-linux-gtk: runs-on: ubuntu-latest @@ -168,3 +183,9 @@ jobs: env: TRAVERSE_REPO: ${{ github.workspace }}/.traverse-checkout run: ./gradlew --no-daemon testDebugUnitTest + + - name: Gradle test meeting-notes + working-directory: apps/meeting-notes/android-compose + env: + TRAVERSE_REPO: ${{ github.workspace }}/.traverse-checkout + run: ./gradlew --no-daemon testDebugUnitTest diff --git a/AGENTS.md b/AGENTS.md index 1f36ad6..348bd4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,8 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \ | `phase2-sidecar-nightly` | Future | Defer — low demo value | | `embed-trace-explorer` | **Done** (#225) | Embedded Trace API in Trace Explorer web | | `registry-ref-starter-process` | **Done** (#224) | Process component uses `registry_ref` | -| `registry-ref-full-kit-cutover` | **In Progress** | All six primary components use `registry_ref` | +| `registry-ref-full-kit-cutover` | **Done** (#235) | All six primary components use `registry_ref` | +| `meeting-notes-wave2-os-ports` | **In Progress** | iOS + macOS + Windows + Android meeting-notes embeds | | `consume-product-wasm-agents` | **Done** (#227) | Digest-pinned Traverse-published starter agents | Full gap table + wave notes: [`docs/production-reference-plan.md`](docs/production-reference-plan.md). diff --git a/README.md b/README.md index 03f849c..b366a97 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ UI examples for [Traverse](https://github.com/traverse-framework/Traverse). |---|---|---| | **traverse-starter** | Submit a short note → title, tags, note type, next action, status | [web](apps/traverse-starter/web-react/) · [all OS](#by-os--target) | | **doc-approval** | Paste a document → type, parties, amounts, confidence, recommendation | [web](apps/doc-approval/web-react/) | -| **meeting-notes** | Paste a transcript → action items, decisions, follow-ups, summary | [web](apps/meeting-notes/web-react/) | +| **meeting-notes** | Paste a transcript → action items, decisions, follow-ups, summary | [web](apps/meeting-notes/web-react/) · [all OS](#by-os--target) | | **trace-explorer** | Browse execution traces (debugger — not a product shell to copy) | [web](apps/trace-explorer/web-react/) | **Extra demos / kits** — useful samples, **not** the production pattern to copy (prefer traverse-starter / doc-approval / meeting-notes): @@ -54,7 +54,7 @@ Same apps on other platforms (`—` = not shipped yet): |---|---|---|---|---|---|---|---| | traverse-starter | [link](apps/traverse-starter/web-react/) | [link](apps/traverse-starter/macos-swift/) | [link](apps/traverse-starter/ios-swift/) | [link](apps/traverse-starter/android-compose/) | [link](apps/traverse-starter/windows-winui/) | [link](apps/traverse-starter/linux-gtk/) | [link](apps/traverse-starter/cli-rust/) | | doc-approval | [link](apps/doc-approval/web-react/) | [link](apps/doc-approval/macos-swift/) | [link](apps/doc-approval/ios-swift/) | [link](apps/doc-approval/android-compose/) | [link](apps/doc-approval/windows-winui/) | [link](apps/doc-approval/linux-gtk/) | [link](apps/doc-approval/cli-rust/) | -| meeting-notes | [link](apps/meeting-notes/web-react/) | — | — | — | — | [link](apps/meeting-notes/linux-gtk/) | [link](apps/meeting-notes/cli-rust/) | +| meeting-notes | [link](apps/meeting-notes/web-react/) | [link](apps/meeting-notes/macos-swift/) | [link](apps/meeting-notes/ios-swift/) | [link](apps/meeting-notes/android-compose/) | [link](apps/meeting-notes/windows-winui/) | [link](apps/meeting-notes/linux-gtk/) | [link](apps/meeting-notes/cli-rust/) | --- diff --git a/apps/meeting-notes/MeetingNotesCore/.gitignore b/apps/meeting-notes/MeetingNotesCore/.gitignore new file mode 100644 index 0000000..0ca9c38 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/.gitignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +Package.resolved diff --git a/apps/meeting-notes/MeetingNotesCore/Package.swift b/apps/meeting-notes/MeetingNotesCore/Package.swift new file mode 100644 index 0000000..2165741 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MeetingNotesCore", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "MeetingNotesCore", targets: ["MeetingNotesCore"]), + ], + dependencies: [ + .package(path: "../../../vendor/traverse-embedder-swift"), + ], + targets: [ + .target( + name: "MeetingNotesCore", + dependencies: [ + .product(name: "TraverseEmbedder", package: "traverse-embedder-swift"), + ] + ), + .testTarget( + name: "MeetingNotesCoreTests", + dependencies: ["MeetingNotesCore"] + ), + ] +) diff --git a/apps/meeting-notes/MeetingNotesCore/README.md b/apps/meeting-notes/MeetingNotesCore/README.md new file mode 100644 index 0000000..51d8a96 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/README.md @@ -0,0 +1,11 @@ +# MeetingNotesCore + +Shared Swift package for meeting-notes iOS and macOS shells. + +- `EmbeddedHost` — `RuntimeTraverseEmbedder` / `InMemoryTraverseEmbedder` boundary +- `AppStateViewModel` — Zone 1 Ready/Unavailable + submit/reset +- `MeetingNotesOutput` — runtime-owned field decoding only + +```bash +cd apps/meeting-notes/MeetingNotesCore && swift test +``` diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift new file mode 100644 index 0000000..f3cccdb --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift @@ -0,0 +1,119 @@ +import Combine +import Foundation + +public enum RuntimeStatus: Equatable, Sendable { + case starting + case ready + case unavailable +} + +/// Drives UI state from an embedded Traverse host. +/// Contains no local business-field computation. +@MainActor +public final class AppStateViewModel: ObservableObject { + @Published public private(set) var currentState: String = "idle" + @Published public private(set) var output: MeetingNotesOutput? + @Published public private(set) var errorMessage: String? + @Published public private(set) var sessionId: String? + @Published public private(set) var trace: [TraceEvent] = [] + @Published public private(set) var runtimeStatus: RuntimeStatus = .starting + @Published public private(set) var submitting: Bool = false + @Published public var transcript: String = "" + @Published public var showTrace: Bool = false + + public let appId: String + public let transcriptMaxLength: Int + public let runtimeMode: String + public let workflowId: String + public private(set) var workspaceId: String + + private let host: EmbeddedHostProtocol? + + public init( + host: EmbeddedHostProtocol?, + workspaceId: String, + appId: String = EmbeddedHost.defaultAppId, + transcriptMaxLength: Int = 5_000 + ) { + self.host = host + self.workspaceId = workspaceId + self.appId = appId + self.transcriptMaxLength = transcriptMaxLength + self.runtimeMode = EmbeddedHost.runtimeModeEmbedded + self.workflowId = host?.workflowId ?? EmbeddedHost.defaultWorkflowId + self.runtimeStatus = host?.isReady == true ? .ready : .unavailable + } + + public var canSubmit: Bool { + runtimeStatus == .ready && + !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !isRunning + } + + public var isRunning: Bool { + submitting || currentState == "processing" + } + + public func updateWorkspace(_ workspaceId: String) { + self.workspaceId = workspaceId + } + + public func refreshRuntimeStatus() { + runtimeStatus = host?.isReady == true ? .ready : .unavailable + } + + public func submit() { + guard canSubmit, let host else { return } + let trimmed = String(transcript.trimmingCharacters(in: .whitespacesAndNewlines).prefix(transcriptMaxLength)) + submitting = true + currentState = "processing" + errorMessage = nil + output = nil + trace = [] + showTrace = false + sessionId = nil + + Task { [weak self] in + guard let self else { return } + do { + let result = try await Task.detached { + try host.submitTranscript(trimmed) + }.value + await MainActor.run { + self.sessionId = result.sessionId + self.trace = result.events + self.showTrace = !result.events.isEmpty + self.submitting = false + if let error = result.error { + self.currentState = "error" + self.errorMessage = error + } else { + self.output = result.output ?? .empty + self.currentState = "completed" + } + } + } catch { + await MainActor.run { + self.submitting = false + self.currentState = "error" + self.errorMessage = error.localizedDescription + } + } + } + } + + public func reset() { + submitting = false + currentState = "idle" + sessionId = nil + output = nil + trace = [] + errorMessage = nil + showTrace = false + } + + /// Compatibility alias for shell call sites. + public func resetLocal() { + reset() + } +} diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift new file mode 100644 index 0000000..f2d7acc --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift @@ -0,0 +1,299 @@ +import Foundation +import TraverseEmbedder + +/// Successful or failed embedded workflow run. +public struct HostRunResult: Equatable, Sendable { + public let sessionId: String + public let output: MeetingNotesOutput? + public let events: [TraceEvent] + public let error: String? + + public init( + sessionId: String, + output: MeetingNotesOutput?, + events: [TraceEvent], + error: String? + ) { + self.sessionId = sessionId + self.output = output + self.events = events + self.error = error + } +} + +/// Embedded Traverse host boundary for SwiftUI shells. +public protocol EmbeddedHostProtocol: AnyObject, Sendable { + var workspaceId: String { get } + var workflowId: String { get } + var isReady: Bool { get } + func submitTranscript(_ transcript: String) throws -> HostRunResult +} + +/// Factory helpers for production and test hosts. +public enum EmbeddedHost { + public static let runtimeModeEmbedded = "Embedded" + public static let defaultWorkflowId = "meeting-notes.process" + public static let defaultWorkspace = "local-default" + public static let defaultAppId = "meeting-notes" + public static let pinnedRuntimeWasmDigest = + "sha256:aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852" + public static let defaultRelativeBundlePath = "bundles/meeting-notes" + + /// Production host backed by the digest-pinned runtime WASM bridge. + public static func tryCreateProduction( + bundleRoot: URL? = nil, + workspaceId: String? = nil, + digest: String? = nil + ) -> EmbeddedHostProtocol? { + do { + guard let root = resolveBundleRoot(override: bundleRoot) else { return nil } + let pinned = digest ?? readPinnedDigest(bundleRoot: root) ?? pinnedRuntimeWasmDigest + let workspace = (workspaceId?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { + $0.isEmpty ? nil : $0 + } ?? defaultWorkspace + return try ProductionEmbeddedHost(bundleRoot: root, digest: pinned, workspaceId: workspace) + } catch { + return nil + } + } + + /// Deterministic test double (spec 068 / #751). + public static func createTestHost( + output: MeetingNotesOutput, + workspaceId: String = defaultWorkspace + ) throws -> EmbeddedHostProtocol { + let encoder = JSONEncoder() + let data = try encoder.encode(output) + let harness = InMemoryTraverseEmbedder().withTargetOutput(data) + let bundle = try TraverseBundle(rootURL: URL(fileURLWithPath: "test-root"), runtimeWasmDigest: "sha256:test") + try harness.initialize(bundle: bundle) + return TestEmbeddedHost(harness: harness, workspaceId: workspaceId, workflowId: defaultWorkflowId) + } + + public static func resolveBundleRoot(override: URL? = nil) -> URL? { + if let override { + let runtime = override.appendingPathComponent("runtime").appendingPathComponent("runtime.wasm") + if FileManager.default.fileExists(atPath: runtime.path) { + return override + } + } + + let candidates: [URL] = [ + Bundle.main.resourceURL? + .appendingPathComponent(defaultRelativeBundlePath), + Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources") + .appendingPathComponent(defaultRelativeBundlePath), + URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(defaultRelativeBundlePath), + ].compactMap { $0 } + + for candidate in candidates { + let runtime = candidate.appendingPathComponent("runtime").appendingPathComponent("runtime.wasm") + if FileManager.default.fileExists(atPath: runtime.path) { + return candidate + } + } + return nil + } + + private static func readPinnedDigest(bundleRoot: URL) -> String? { + let release = bundleRoot.appendingPathComponent("runtime").appendingPathComponent("runtime-release.json") + guard let data = try? Data(contentsOf: release), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hex = json["sha256"] as? String, + !hex.isEmpty else { + return nil + } + return hex.hasPrefix("sha256:") ? hex : "sha256:\(hex)" + } +} + +private final class ProductionEmbeddedHost: EmbeddedHostProtocol, @unchecked Sendable { + let workspaceId: String + let workflowId: String + let isReady: Bool + private let client: WasmiHostBridgeClient + private let runtime: RuntimeTraverseEmbedder + + init(bundleRoot: URL, digest: String, workspaceId: String) throws { + self.workspaceId = workspaceId + self.workflowId = EmbeddedHost.defaultWorkflowId + let bundle = try TraverseBundle(rootURL: bundleRoot, runtimeWasmDigest: digest) + self.client = try WasmiHostBridgeClient(bundle: bundle) + self.runtime = RuntimeTraverseEmbedder(client: client) + let config = try JSONSerialization.data(withJSONObject: ["workspace_id": workspaceId]) + _ = try runtime.initialize(configJSON: config) + self.isReady = true + } + + func submitTranscript(_ transcript: String) throws -> HostRunResult { + let input = try JSONSerialization.data(withJSONObject: ["transcript": transcript]) + let submission = try TraverseSubmission(targetID: workflowId, inputJSON: input) + let accepted = try runtime.submit(submission) + guard accepted.status.lowercased() == "accepted" else { + return HostRunResult( + sessionId: accepted.sessionID, + output: nil, + events: [], + error: "submit \(accepted.status)" + ) + } + return try drainEvents(sessionId: accepted.sessionID) + } + + private func drainEvents(sessionId: String) throws -> HostRunResult { + var events: [TraceEvent] = [] + var output: MeetingNotesOutput? + var error: String? + + while let bytes = try client.nextEvent() { + guard let root = try JSONSerialization.jsonObject(with: bytes) as? [String: Any] else { + continue + } + let eventType = (root["type"] as? String) + ?? (root["event_type"] as? String) + ?? "event" + if let eventSession = root["session_id"] as? String, + eventSession != sessionId { + continue + } + let data = root["data"].map { JSONValue.fromAny($0) } ?? nil + events.append(TraceEvent(event_type: eventType, timestamp: "\(events.count)", data: data)) + + if eventType == "error" { + error = extractError(root["data"]) ?? "execution failed" + break + } + if eventType == "capability_result" { + output = parseOutput(root["data"]) + break + } + } + + if let error { + return HostRunResult(sessionId: sessionId, output: nil, events: events, error: error) + } + if output == nil, events.isEmpty { + return HostRunResult( + sessionId: sessionId, + output: nil, + events: events, + error: "embedder emitted no capability_result" + ) + } + return HostRunResult( + sessionId: sessionId, + output: output ?? .empty, + events: events, + error: nil + ) + } + + deinit { + _ = try? runtime.shutdown() + } +} + +private final class TestEmbeddedHost: EmbeddedHostProtocol, @unchecked Sendable { + let workspaceId: String + let workflowId: String + let isReady: Bool = true + private let harness: InMemoryTraverseEmbedder + + init(harness: InMemoryTraverseEmbedder, workspaceId: String, workflowId: String) { + self.harness = harness + self.workspaceId = workspaceId + self.workflowId = workflowId + } + + func submitTranscript(_ transcript: String) throws -> HostRunResult { + let input = try JSONSerialization.data(withJSONObject: ["transcript": transcript]) + let submission = try TraverseSubmission(targetID: workflowId, inputJSON: input) + let accepted = try harness.submit(submission) + let runtimeEvents = try harness.subscribe() + var events: [TraceEvent] = [] + var output: MeetingNotesOutput? + var error: String? + + for evt in runtimeEvents { + if let sid = evt.sessionID, sid != accepted.sessionID { continue } + let eventType = evt.eventType ?? evt.status + var data: JSONValue? + if let out = evt.output, + let obj = try? JSONSerialization.jsonObject(with: out) { + data = JSONValue.fromAny(obj) + } + events.append(TraceEvent(event_type: eventType, timestamp: "\(evt.sequence)", data: data)) + if eventType == "error" { + error = evt.errorData.flatMap { String(data: $0, encoding: .utf8) } ?? "execution failed" + break + } + if eventType == "capability_result", let out = evt.output { + output = (try? JSONDecoder().decode(MeetingNotesOutput.self, from: out)) ?? .empty + break + } + } + + if let error { + return HostRunResult(sessionId: accepted.sessionID, output: nil, events: events, error: error) + } + return HostRunResult( + sessionId: accepted.sessionID, + output: output ?? .empty, + events: events, + error: output == nil ? "embedder emitted no capability_result" : nil + ) + } + + deinit { + harness.shutdown() + } +} + +private func parseOutput(_ raw: Any?) -> MeetingNotesOutput { + guard let raw else { return .empty } + var value = raw + if let dict = raw as? [String: Any], let nested = dict["output"] { + value = nested + } + if let dict = value as? [String: Any], + let parsed = MeetingNotesOutputParser.parse(dict) { + return parsed + } + if let data = try? JSONSerialization.data(withJSONObject: value), + let decoded = try? JSONDecoder().decode(MeetingNotesOutput.self, from: data) { + return decoded + } + return .empty +} + +private func extractError(_ raw: Any?) -> String? { + guard let dict = raw as? [String: Any] else { return nil } + if let err = dict["error"] as? String { return err } + if let err = dict["error"] as? [String: Any], let message = err["message"] as? String { + return message + } + return nil +} + +private extension JSONValue { + static func fromAny(_ value: Any) -> JSONValue { + switch value { + case let s as String: return .string(s) + case let n as NSNumber: + if CFGetTypeID(n) == CFBooleanGetTypeID() { + return .bool(n.boolValue) + } + return .number(n.doubleValue) + case let dict as [String: Any]: + return .object(dict.mapValues { fromAny($0) }) + case let arr as [Any]: + return .array(arr.map { fromAny($0) }) + case is NSNull: + return .null + default: + return .null + } + } +} diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesCommand.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesCommand.swift new file mode 100644 index 0000000..bb40226 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesCommand.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct MeetingNotesCommand: 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(transcript: String, sessionId: String? = nil) -> MeetingNotesCommand { + MeetingNotesCommand(name: "submit", payload: ["transcript": transcript], sessionId: sessionId) + } +} diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift new file mode 100644 index 0000000..12f68e4 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift @@ -0,0 +1,202 @@ +import Foundation + +public struct ActionItem: Equatable, Sendable, Codable { + public let task: String + public let owner: String? + public let due: String? + + public init(task: String, owner: String? = nil, due: String? = nil) { + self.task = task + self.owner = owner + self.due = due + } +} + +public struct Decision: Equatable, Sendable, Codable { + public let text: String + public let madeBy: String? + + public init(text: String, madeBy: String? = nil) { + self.text = text + self.madeBy = madeBy + } + + enum CodingKeys: String, CodingKey { + case text + case madeBy = "made_by" + } +} + +/// Runtime-owned meeting-notes.process output. +public struct MeetingNotesOutput: Equatable, Sendable, Codable { + public let actionItems: [ActionItem] + public let decisions: [Decision] + public let followUps: [String] + public let summary: String + + public init( + actionItems: [ActionItem], + decisions: [Decision], + followUps: [String], + summary: String + ) { + self.actionItems = actionItems + self.decisions = decisions + self.followUps = followUps + self.summary = summary + } + + public static let empty = MeetingNotesOutput( + actionItems: [], + decisions: [], + followUps: [], + summary: "" + ) + + enum CodingKeys: String, CodingKey { + case actionItems = "action_items" + case decisions + case followUps = "follow_ups" + case summary + } +} + +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 AppStateEventPayload: Equatable, Sendable { + public let state: String? + public let sessionId: String? + public let executionId: String? + public let output: MeetingNotesOutput? + public let errorMessage: String? + + public init( + state: String? = nil, + sessionId: String? = nil, + executionId: String? = nil, + output: MeetingNotesOutput? = nil, + errorMessage: String? = nil + ) { + self.state = state + self.sessionId = sessionId + self.executionId = executionId + self.output = output + self.errorMessage = errorMessage + } +} + +public enum MeetingNotesClientError: 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 MeetingNotesOutputParser { + public static func parse(_ raw: Any?) -> MeetingNotesOutput? { + guard let dict = raw as? [String: Any], + let summary = dict["summary"] as? String else { + return nil + } + let actionItems = parseActionItems(dict["action_items"]) + let decisions = parseDecisions(dict["decisions"]) + let followUps = dict["follow_ups"] as? [String] ?? [] + return MeetingNotesOutput( + actionItems: actionItems, + decisions: decisions, + followUps: followUps, + summary: summary + ) + } + + 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 + ) + } + + private static func parseActionItems(_ raw: Any?) -> [ActionItem] { + guard let items = raw as? [[String: Any]] else { return [] } + return items.compactMap { item in + guard let task = item["task"] as? String else { return nil } + return ActionItem( + task: task, + owner: item["owner"] as? String, + due: item["due"] as? String + ) + } + } + + private static func parseDecisions(_ raw: Any?) -> [Decision] { + guard let items = raw as? [[String: Any]] else { return [] } + return items.compactMap { item in + guard let text = item["text"] as? String else { return nil } + return Decision(text: text, madeBy: item["made_by"] as? String) + } + } +} diff --git a/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/MeetingNotesCoreTests.swift b/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/MeetingNotesCoreTests.swift new file mode 100644 index 0000000..bc2e855 --- /dev/null +++ b/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/MeetingNotesCoreTests.swift @@ -0,0 +1,95 @@ +import Foundation +import XCTest +@testable import MeetingNotesCore + +final class EmbeddedHostTests: XCTestCase { + private var sampleOutput: MeetingNotesOutput { + MeetingNotesOutput( + actionItems: [ActionItem(task: "Ship clients", owner: "Alex", due: "Friday")], + decisions: [Decision(text: "Use embedded runtime", madeBy: "Team")], + followUps: ["Schedule review"], + summary: "Discussed Wave 2 ports" + ) + } + + func testTestHostReturnsScriptedCapabilityResult() throws { + let host = try EmbeddedHost.createTestHost(output: sampleOutput) + let result = try host.submitTranscript("any transcript") + XCTAssertNil(result.error) + XCTAssertEqual(result.output?.summary, "Discussed Wave 2 ports") + XCTAssertTrue(result.events.contains { $0.event_type == "capability_result" }) + } + + func testPinnedDigestConstant() { + XCTAssertTrue(EmbeddedHost.pinnedRuntimeWasmDigest.hasPrefix("sha256:")) + XCTAssertEqual(EmbeddedHost.pinnedRuntimeWasmDigest.count, 71) + } +} + +@MainActor +final class AppStateViewModelTests: XCTestCase { + private var sampleOutput: MeetingNotesOutput { + MeetingNotesOutput( + actionItems: [ActionItem(task: "Ship clients")], + decisions: [], + followUps: [], + summary: "Discussed Wave 2 ports" + ) + } + + func testCanSubmitWhenReadyWithTranscript() throws { + let host = try EmbeddedHost.createTestHost(output: sampleOutput) + let vm = AppStateViewModel(host: host, workspaceId: "local-default") + vm.transcript = "hello" + XCTAssertEqual(vm.runtimeStatus, .ready) + XCTAssertEqual(vm.runtimeMode, EmbeddedHost.runtimeModeEmbedded) + XCTAssertTrue(vm.canSubmit) + } + + func testUnavailableHostDisablesSubmit() { + let vm = AppStateViewModel(host: nil, workspaceId: "local-default") + vm.transcript = "hello" + XCTAssertEqual(vm.runtimeStatus, .unavailable) + XCTAssertFalse(vm.canSubmit) + } + + func testSubmitTransitionsToCompleted() async throws { + let host = try EmbeddedHost.createTestHost(output: sampleOutput) + let vm = AppStateViewModel(host: host, workspaceId: "local-default") + vm.transcript = "meeting transcript" + vm.submit() + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertEqual(vm.currentState, "completed") + XCTAssertEqual(vm.output?.summary, "Discussed Wave 2 ports") + XCTAssertNotNil(vm.sessionId) + } + + func testResetReturnsToIdle() throws { + let host = try EmbeddedHost.createTestHost(output: sampleOutput) + let vm = AppStateViewModel(host: host, workspaceId: "local-default") + vm.errorMessage = "boom" + vm.reset() + XCTAssertEqual(vm.currentState, "idle") + XCTAssertNil(vm.errorMessage) + } +} + +final class MeetingNotesOutputParserTests: XCTestCase { + func testParseProcessOutput() { + let raw: [String: Any] = [ + "action_items": [ + ["task": "Ship clients", "owner": "Alex", "due": "Friday"], + ], + "decisions": [ + ["text": "Use embedded runtime", "made_by": "Team"], + ], + "follow_ups": ["Schedule review"], + "summary": "Discussed Wave 2 ports", + ] + let output = MeetingNotesOutputParser.parse(raw) + XCTAssertEqual(output?.summary, "Discussed Wave 2 ports") + XCTAssertEqual(output?.actionItems.first?.task, "Ship clients") + XCTAssertEqual(output?.decisions.first?.madeBy, "Team") + XCTAssertEqual(output?.followUps, ["Schedule review"]) + } +} diff --git a/apps/meeting-notes/android-compose/.gitignore b/apps/meeting-notes/android-compose/.gitignore new file mode 100644 index 0000000..e4f1161 --- /dev/null +++ b/apps/meeting-notes/android-compose/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.DS_Store +captures/ +.externalNativeBuild/ +.cxx/ diff --git a/apps/meeting-notes/android-compose/README.md b/apps/meeting-notes/android-compose/README.md new file mode 100644 index 0000000..3fe47e6 --- /dev/null +++ b/apps/meeting-notes/android-compose/README.md @@ -0,0 +1,48 @@ +# meeting-notes (Android Compose) + +**Runtime mode: Embedded** - public Kotlin `TraverseEmbedder` (`dev.traverse.embedder`) with digest-pinned `runtime/runtime.wasm`. No `traverse-cli serve` sidecar is required. + +Native Android client for the `meeting-notes` reference app. + +## Prerequisites + +- Android Studio Ladybug+ (or compatible AGP 8.7 / Kotlin 2.0) +- Android emulator API 28+ (or physical device) +- Traverse checkout with the Kotlin embedder package and certified runtime artifact: + +```bash +export TRAVERSE_REPO=/path/to/Traverse +bash scripts/ci/sync_android_meeting_notes_bundle.sh +``` + +`settings.gradle.kts` composites `$TRAVERSE_REPO/packages/kotlin/TraverseEmbedder` as `:traverse-embedder`. + +## Bundle configuration + +Assets live under `app/src/main/assets/bundles/meeting-notes/` (including `runtime/runtime.wasm` + `runtime-release.json` after sync). The app copies them into `filesDir` on launch. + +Settings -> Workspace only (no sidecar URL). Default workspace is `local-default`. + +## Build and test + +```bash +export TRAVERSE_REPO=/path/to/Traverse +cd apps/meeting-notes/android-compose +./gradlew test +./gradlew :app:assembleDebug +``` + +Unit tests inject `InMemoryTraverseEmbedder` via `InMemoryMeetingNotesHost`; fixtures use only the runtime-owned `MeetingNotesOutput` fields. + +## Architecture + +| File | Role | +|---|---| +| `EmbeddedHost.kt` | Production + in-memory embedded hosts | +| `ExecutionViewModel.kt` | Submit transcript -> render runtime-owned output | +| `BundleAssets.kt` | Materialize asset bundle into filesDir | +| `ui/MainScreen.kt` | Embedded runtime status, transcript input, output, trace | + +## Design language + +Follow [docs/design-language.md](../../../docs/design-language.md). Zone 1 shows **Embedded** runtime mode. diff --git a/apps/meeting-notes/android-compose/app/build.gradle.kts b/apps/meeting-notes/android-compose/app/build.gradle.kts new file mode 100644 index 0000000..4a04f42 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.traverseframework.meetingnotes" + compileSdk = 35 + + defaultConfig { + applicationId = "com.traverseframework.meetingnotes" + minSdk = 28 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.4") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + // Public Traverse Kotlin embedder (composite via settings.gradle.kts + TRAVERSE_REPO) + implementation(project(":traverse-embedder")) + + debugImplementation("androidx.compose.ui:ui-tooling") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +} diff --git a/apps/meeting-notes/android-compose/app/proguard-rules.pro b/apps/meeting-notes/android-compose/app/proguard-rules.pro new file mode 100644 index 0000000..60ba34d --- /dev/null +++ b/apps/meeting-notes/android-compose/app/proguard-rules.pro @@ -0,0 +1 @@ +# traverse-starter Android — default keep rules diff --git a/apps/meeting-notes/android-compose/app/src/main/AndroidManifest.xml b/apps/meeting-notes/android-compose/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8e50f4a --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/app.manifest.json b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/app.manifest.json new file mode 100644 index 0000000..06486a2 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/app.manifest.json @@ -0,0 +1,117 @@ +{ + "app_id": "meeting-notes", + "version": "1.0.0", + "schema_version": "1.0.0", + "workspace_defaults": { + "workspace_id": "local-default", + "registry_scope": "private" + }, + "components": [ + { + "component_id": "meeting-notes.process-component", + "version": "1.0.0", + "digest": "sha256:5647c39a1d25d8728350f9619025292a62e78a602068a2ad9b6f075751c93d99", + "manifest_path": "components/process/component.manifest.json" + } + ], + "workflows": [ + { + "workflow_id": "meeting-notes.process", + "workflow_version": "1.0.0", + "path": "_traverse/workflows/examples/meeting-notes/process/workflow.json" + } + ], + "model_dependencies": [], + "config_schema": { + "type": "object", + "required": [ + "workspace_id" + ], + "properties": { + "workspace_id": { + "type": "string" + }, + "processing_mode": { + "type": "string", + "enum": [ + "deterministic" + ] + } + }, + "additionalProperties": false + }, + "default_config": { + "workspace_id": "local-default", + "processing_mode": "deterministic" + }, + "placement_policy": { + "preferred_targets": [ + "local" + ], + "allow_fallback": false + }, + "public_surfaces": [ + "cli", + "http_json" + ], + "state_machine": { + "initial_state": "idle", + "list_context_fields": [ + "output.action_items", + "output.decisions", + "output.follow_ups", + "output.summary" + ], + "states": [ + { + "id": "idle", + "transitions": [ + { + "on": "submit", + "to": "processing" + } + ] + }, + { + "id": "processing", + "invoke": { + "capability_id": "meeting-notes.process", + "input_from": "command.payload" + }, + "transitions": [ + { + "on": "capability_succeeded", + "to": "results" + }, + { + "on": "capability_failed", + "to": "error" + } + ] + }, + { + "id": "results", + "transitions": [ + { + "on": "reset", + "to": "idle" + } + ] + }, + { + "id": "error", + "transitions": [ + { + "on": "retry", + "to": "processing", + "with_last_payload": true + }, + { + "on": "reset", + "to": "idle" + } + ] + } + ] + } +} diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/components/process/component.manifest.json b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/components/process/component.manifest.json new file mode 100644 index 0000000..dec21bd --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/components/process/component.manifest.json @@ -0,0 +1,30 @@ +{ + "component_id": "meeting-notes.process-component", + "version": "1.0.0", + "schema_version": "1.0.0", + "capability_id": "meeting-notes.process", + "capability_version": "1.0.0", + "registry_ref": { + "namespace": "meeting-notes", + "id": "meeting-notes.process", + "version_range": "^1.0.0" + }, + "runtime_constraints": { + "host_api_access": "none", + "network_access": "forbidden", + "filesystem_access": "none" + }, + "permitted_targets": [ + "local", + "device" + ], + "dependencies": [], + "connector_requirements": [], + "validation_evidence": [ + { + "evidence_type": "checked_in_fixture", + "status": "passed", + "produced_by": "meeting_notes_example_smoke" + } + ] +} diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime-release.json b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime-release.json new file mode 100644 index 0000000..b3354d7 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime-release.json @@ -0,0 +1 @@ +{"runtime_version":"0.8.1","bridge_version":"1.1.0","bridge_abi_version":10100,"sha256":"aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852"} diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime.wasm b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime.wasm new file mode 100644 index 0000000..9587c31 Binary files /dev/null and b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime.wasm differ diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/BundleAssets.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/BundleAssets.kt new file mode 100644 index 0000000..05de56f --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/BundleAssets.kt @@ -0,0 +1,38 @@ +package com.traverseframework.meetingnotes + +import android.content.Context +import java.io.File + +/** Copies bundled Traverse runtime assets from APK assets into app filesDir. */ +object BundleAssets { + fun materialize(context: Context, assetDir: String = AppConstants.BUNDLE_ASSET_DIR): File { + val dest = File(context.filesDir, assetDir) + copyAssetTree(context, assetDir, dest) + return dest + } + + private fun copyAssetTree(context: Context, assetPath: String, destDir: File) { + val children = context.assets.list(assetPath) ?: return + if (children.isEmpty()) { + // leaf file + destDir.parentFile?.mkdirs() + context.assets.open(assetPath).use { input -> + destDir.outputStream().use { output -> input.copyTo(output) } + } + return + } + destDir.mkdirs() + for (child in children) { + val childAsset = "$assetPath/$child" + val childDest = File(destDir, child) + val grand = context.assets.list(childAsset) + if (grand.isNullOrEmpty()) { + context.assets.open(childAsset).use { input -> + childDest.outputStream().use { output -> input.copyTo(output) } + } + } else { + copyAssetTree(context, childAsset, childDest) + } + } + } +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt new file mode 100644 index 0000000..b388067 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt @@ -0,0 +1,146 @@ +package com.traverseframework.meetingnotes + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File + +/** + * Embedded Traverse host for meeting-notes. + * + * Production uses the public Kotlin `dev.traverse.embedder` package. + * Unit tests inject [InMemoryMeetingNotesHost] with scripted runtime-owned output. + */ +interface MeetingNotesHost { + val runtimeMode: String + val isReady: Boolean + fun submitTranscript(transcript: String): HostRunResult +} + +data class HostRunResult( + val sessionId: String, + val output: MeetingNotesOutput?, + val events: List, + val error: String?, +) + +/** Deterministic test double wrapping [dev.traverse.embedder.InMemoryTraverseEmbedder]. */ +class InMemoryMeetingNotesHost( + private val scriptedOutputJson: String, +) : MeetingNotesHost { + override val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED + override val isReady: Boolean = true + + private val embedder = dev.traverse.embedder.InMemoryTraverseEmbedder() + .withTargetOutput(scriptedOutputJson) + .also { + it.initialize( + dev.traverse.embedder.TraverseBundle( + rootPath = "test-bundle", + runtimeWasmDigest = "sha256:test", + ), + ) + } + + override fun submitTranscript(transcript: String): HostRunResult { + val inputJson = buildJsonObject { put("transcript", transcript) }.toString() + val result = embedder.submit( + dev.traverse.embedder.TraverseSubmission(AppConstants.CAPABILITY_ID, inputJson), + ) + val events = embedder.subscribe() + val outputJson = events.firstOrNull { it.eventType == "capability_result" }?.output + val output = outputJson?.let { parseOutput(it) } + return HostRunResult( + sessionId = result.sessionId, + output = output, + events = events.map { + TraceEvent( + event_type = it.eventType ?: it.status, + timestamp = it.sequence.toString(), + data = null, + ) + }, + error = if (output == null) "embedder emitted no capability_result output" else null, + ) + } + + companion object { + private val json = Json { ignoreUnknownKeys = true } + + fun parseOutput(raw: String): MeetingNotesOutput? = try { + json.decodeFromString(MeetingNotesOutput.serializer(), raw) + } catch (_: Exception) { + null + } + + fun withScriptedOutput(output: MeetingNotesOutput): InMemoryMeetingNotesHost = + InMemoryMeetingNotesHost(json.encodeToString(MeetingNotesOutput.serializer(), output)) + } +} + +/** + * Production host: digest-pinned `runtime/runtime.wasm` via public [RuntimeTraverseEmbedder] + * constructed from [TraverseBundle] (public constructor). + */ +class ProductionMeetingNotesHost private constructor( + private val embedder: dev.traverse.embedder.RuntimeTraverseEmbedder, +) : MeetingNotesHost { + override val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED + override val isReady: Boolean = true + + override fun submitTranscript(transcript: String): HostRunResult = try { + val inputJson = buildJsonObject { put("transcript", transcript) }.toString() + val result = embedder.submit( + dev.traverse.embedder.TraverseSubmission(AppConstants.CAPABILITY_ID, inputJson), + ) + val runtimeEvents = embedder.subscribe() + val events = runtimeEvents.map { + TraceEvent( + event_type = it.eventType ?: it.status, + timestamp = it.sequence.toString(), + data = null, + ) + } + val output = runtimeEvents + .firstOrNull { it.eventType == "capability_result" || it.output != null } + ?.output + ?.let { InMemoryMeetingNotesHost.parseOutput(it) } + HostRunResult( + sessionId = result.sessionId, + output = output, + events = events, + error = if (output == null) { + "runtime returned no meeting-notes output" + } else { + null + }, + ) + } catch (e: Exception) { + HostRunResult("", null, emptyList(), e.message ?: "submit failed") + } + + companion object { + fun createOrNull(bundleRoot: File): ProductionMeetingNotesHost? { + val wasm = File(bundleRoot, "runtime/runtime.wasm") + val release = File(bundleRoot, "runtime/runtime-release.json") + if (!wasm.isFile || !release.isFile) return null + val digestHex = Regex("\"sha256\"\\s*:\\s*\"([^\"]+)\"") + .find(release.readText()) + ?.groupValues + ?.get(1) + ?: return null + return try { + val bundle = dev.traverse.embedder.TraverseBundle( + rootPath = bundleRoot.absolutePath, + runtimeWasmDigest = "sha256:$digestHex", + ) + val embedder = dev.traverse.embedder.RuntimeTraverseEmbedder(bundle) + embedder.initialize("{}") + ProductionMeetingNotesHost(embedder) + } catch (_: Exception) { + null + } + } + } +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionUiState.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionUiState.kt new file mode 100644 index 0000000..c380be7 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionUiState.kt @@ -0,0 +1,31 @@ +package com.traverseframework.meetingnotes + +sealed interface ExecutionPhase { + data object Idle : ExecutionPhase + data object Loading : ExecutionPhase + data class Succeeded(val output: MeetingNotesOutput, val trace: List) : ExecutionPhase + data class Failed(val error: String) : ExecutionPhase +} + +enum class RuntimeStatus { + Starting, + Ready, + Unavailable, +} + +data class ExecutionUiState( + val phase: ExecutionPhase = ExecutionPhase.Idle, + val transcript: String = "", + val runtimeStatus: RuntimeStatus = RuntimeStatus.Starting, + val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED, + val workspace: String = AppConstants.DEFAULT_WORKSPACE, + val showTrace: Boolean = false, +) { + val isRunning: Boolean + get() = phase is ExecutionPhase.Loading + + val canSubmit: Boolean + get() = runtimeStatus == RuntimeStatus.Ready && + transcript.trim().isNotEmpty() && + !isRunning +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModel.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModel.kt new file mode 100644 index 0000000..ebf074a --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModel.kt @@ -0,0 +1,75 @@ +package com.traverseframework.meetingnotes + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ExecutionViewModel( + private val host: MeetingNotesHost, + private val settings: RuntimeSettings, + private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : ViewModel() { + private val _uiState = MutableStateFlow( + ExecutionUiState( + runtimeStatus = if (host.isReady) RuntimeStatus.Ready else RuntimeStatus.Unavailable, + runtimeMode = host.runtimeMode, + workspace = AppConstants.DEFAULT_WORKSPACE, + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private var submitJob: Job? = null + + init { + viewModelScope.launch { + settings.workspace.collect { workspace -> + _uiState.update { it.copy(workspace = workspace) } + } + } + } + + fun updateTranscript(transcript: String) { + _uiState.update { it.copy(transcript = transcript.take(AppConstants.TRANSCRIPT_MAX_LENGTH)) } + } + + fun toggleTrace(show: Boolean) { + _uiState.update { it.copy(showTrace = show) } + } + + fun submit() { + val state = _uiState.value + if (!state.canSubmit) return + submitJob?.cancel() + _uiState.update { it.copy(phase = ExecutionPhase.Loading) } + val transcript = state.transcript.trim() + submitJob = viewModelScope.launch { + val result = withContext(computeDispatcher) { host.submitTranscript(transcript) } + if (result.error != null && result.output == null) { + _uiState.update { it.copy(phase = ExecutionPhase.Failed(result.error)) } + } else { + _uiState.update { + it.copy( + phase = ExecutionPhase.Succeeded( + result.output ?: MeetingNotesOutput.EMPTY, + result.events, + ), + ) + } + } + } + } + + fun reset() { + submitJob?.cancel() + submitJob = null + _uiState.update { it.copy(phase = ExecutionPhase.Idle, showTrace = false) } + } +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModelFactory.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModelFactory.kt new file mode 100644 index 0000000..e9e7a1d --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModelFactory.kt @@ -0,0 +1,17 @@ +package com.traverseframework.meetingnotes + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class ExecutionViewModelFactory( + private val host: MeetingNotesHost, + private val settings: RuntimeSettings, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ExecutionViewModel::class.java)) { + return ExecutionViewModel(host, settings) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/MainActivity.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/MainActivity.kt new file mode 100644 index 0000000..0bf2a4d --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/MainActivity.kt @@ -0,0 +1,63 @@ +package com.traverseframework.meetingnotes + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.traverseframework.meetingnotes.ui.MainScreen +import com.traverseframework.meetingnotes.ui.MeetingNotesTheme +import com.traverseframework.meetingnotes.ui.SettingsScreen + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val settings = SettingsRepository(applicationContext) + val bundleRoot = BundleAssets.materialize(applicationContext) + val host: MeetingNotesHost = ProductionMeetingNotesHost.createOrNull(bundleRoot) + ?: UnavailableMeetingNotesHost + setContent { + MeetingNotesTheme { + val navController = rememberNavController() + val viewModel: ExecutionViewModel = viewModel( + factory = ExecutionViewModelFactory(host, settings), + ) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + NavHost(navController = navController, startDestination = "main") { + composable("main") { + MainScreen( + uiState = uiState, + onTranscriptChange = viewModel::updateTranscript, + onSubmit = viewModel::submit, + onReset = viewModel::reset, + onOpenSettings = { navController.navigate("settings") }, + onTraceToggle = viewModel::toggleTrace, + ) + } + composable("settings") { + SettingsScreen( + settings = settings, + currentWorkspace = uiState.workspace, + onBack = { navController.popBackStack() }, + ) + } + } + } + } + } +} + +/** Fallback when the digest-pinned runtime bundle is missing from assets. */ +object UnavailableMeetingNotesHost : MeetingNotesHost { + override val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED + override val isReady: Boolean = false + override fun submitTranscript(transcript: String): HostRunResult = + HostRunResult("", null, emptyList(), "embedded runtime unavailable - sync the app bundle") +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/Models.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/Models.kt new file mode 100644 index 0000000..91f8168 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/Models.kt @@ -0,0 +1,49 @@ +package com.traverseframework.meetingnotes + +import kotlinx.serialization.json.JsonElement + +@kotlinx.serialization.Serializable +data class ActionItem( + val task: String, + val owner: String? = null, + val due: String? = null, +) + +@kotlinx.serialization.Serializable +data class Decision( + val text: String, + val made_by: String? = null, +) + +@kotlinx.serialization.Serializable +data class MeetingNotesOutput( + val action_items: List, + val decisions: List, + val follow_ups: List, + val summary: String, +) { + companion object { + val EMPTY = MeetingNotesOutput( + action_items = emptyList(), + decisions = emptyList(), + follow_ups = emptyList(), + summary = "", + ) + } +} + +@kotlinx.serialization.Serializable +data class TraceEvent( + val event_type: String, + val timestamp: String, + val data: JsonElement? = null, +) + +object AppConstants { + const val CAPABILITY_ID = "meeting-notes.process" + const val RUNTIME_MODE_EMBEDDED = "Embedded" + const val DEFAULT_WORKSPACE = "local-default" + const val TRANSCRIPT_MAX_LENGTH = 5000 + /** Asset-relative bundle root (must include runtime/runtime.wasm after sync). */ + const val BUNDLE_ASSET_DIR = "bundles/meeting-notes" +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/SettingsRepository.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/SettingsRepository.kt new file mode 100644 index 0000000..9d770c7 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/SettingsRepository.kt @@ -0,0 +1,29 @@ +package com.traverseframework.meetingnotes + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface RuntimeSettings { + val workspace: Flow + suspend fun setWorkspace(workspace: String) +} + +private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "meeting_notes_settings") + +class SettingsRepository(private val context: Context) : RuntimeSettings { + private val workspaceKey = stringPreferencesKey("workspace") + + override val workspace: Flow = context.settingsDataStore.data.map { prefs -> + prefs[workspaceKey] ?: AppConstants.DEFAULT_WORKSPACE + } + + override suspend fun setWorkspace(workspace: String) { + context.settingsDataStore.edit { it[workspaceKey] = workspace } + } +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/MainScreen.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/MainScreen.kt new file mode 100644 index 0000000..89c6984 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/MainScreen.kt @@ -0,0 +1,243 @@ +package com.traverseframework.meetingnotes.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.traverseframework.meetingnotes.ActionItem +import com.traverseframework.meetingnotes.AppConstants +import com.traverseframework.meetingnotes.Decision +import com.traverseframework.meetingnotes.ExecutionPhase +import com.traverseframework.meetingnotes.ExecutionUiState +import com.traverseframework.meetingnotes.MeetingNotesOutput +import com.traverseframework.meetingnotes.RuntimeStatus + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + uiState: ExecutionUiState, + onTranscriptChange: (String) -> Unit, + onSubmit: () -> Unit, + onReset: () -> Unit, + onOpenSettings: () -> Unit, + onTraceToggle: (Boolean) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("meeting-notes") }, + actions = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 12.dp), + ) { + StatusDot(uiState.runtimeStatus) + Text( + text = statusLabel(uiState.runtimeStatus), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 8.dp), + ) + Button(onClick = onOpenSettings, modifier = Modifier.padding(start = 8.dp)) { + Text("Settings") + } + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + RuntimeCard(uiState) + InputCard(uiState, onTranscriptChange, onSubmit) + OutputCard(uiState, onReset, onTraceToggle) + } + } +} + +@Composable +private fun RuntimeCard(uiState: ExecutionUiState) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Runtime Environment", style = MaterialTheme.typography.titleMedium) + Text("mode: ${uiState.runtimeMode}", style = MaterialTheme.typography.bodySmall) + Text("workspace: ${uiState.workspace}", style = MaterialTheme.typography.bodySmall) + Text("capability: ${AppConstants.CAPABILITY_ID}", style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun InputCard( + uiState: ExecutionUiState, + onTranscriptChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Process Transcript", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = uiState.transcript, + onValueChange = onTranscriptChange, + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + placeholder = { Text("Paste meeting transcript...") }, + ) + Text("${uiState.transcript.length}/${AppConstants.TRANSCRIPT_MAX_LENGTH}") + Button(onClick = onSubmit, enabled = uiState.canSubmit, modifier = Modifier.fillMaxWidth()) { + Text(if (uiState.isRunning) "Processing..." else "Submit Transcript") + } + if (uiState.runtimeStatus == RuntimeStatus.Unavailable) { + Text( + "Embedded runtime unavailable - sync the bundle with scripts/ci/sync_android_meeting_notes_bundle.sh (requires TRAVERSE_REPO).", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun OutputCard( + uiState: ExecutionUiState, + onReset: () -> Unit, + onTraceToggle: (Boolean) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Output", style = MaterialTheme.typography.titleMedium) + when (val phase = uiState.phase) { + ExecutionPhase.Idle -> { + Text( + if (uiState.runtimeStatus == RuntimeStatus.Unavailable) { + "Initialize the embedded runtime to see meeting notes output here." + } else { + "Submit a transcript above to run meeting-notes.process." + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + ExecutionPhase.Loading -> Text("Running embedded workflow...") + is ExecutionPhase.Failed -> Text("Error: ${phase.error}", color = MaterialTheme.colorScheme.error) + is ExecutionPhase.Succeeded -> { + OutputFields(phase.output) + if (phase.trace.isNotEmpty()) { + Button(onClick = { onTraceToggle(!uiState.showTrace) }) { + Text("Trace (${phase.trace.size} events)") + } + if (uiState.showTrace) { + phase.trace.forEach { event -> + Text("${event.timestamp} - ${event.event_type}", style = MaterialTheme.typography.bodySmall) + } + } + } + Button(onClick = onReset) { Text("Reset") } + } + } + } + } +} + +@Composable +private fun OutputFields(output: MeetingNotesOutput) { + ActionItemsSection(output.action_items) + Spacer(modifier = Modifier.height(8.dp)) + DecisionsSection(output.decisions) + Spacer(modifier = Modifier.height(8.dp)) + StringListSection("Follow-ups", output.follow_ups) + Spacer(modifier = Modifier.height(8.dp)) + Text("Summary", style = MaterialTheme.typography.titleSmall) + Text(output.summary) +} + +@Composable +private fun ActionItemsSection(items: List) { + Text("Action Items", style = MaterialTheme.typography.titleSmall) + if (items.isEmpty()) { + Text("None recorded", color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + items.forEach { item -> + Text("- ${item.task}") + val details = listOfNotNull(item.owner, item.due?.let { "due $it" }) + if (details.isNotEmpty()) { + Text(details.joinToString(" | "), style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun DecisionsSection(items: List) { + Text("Decisions", style = MaterialTheme.typography.titleSmall) + if (items.isEmpty()) { + Text("None recorded", color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + items.forEach { item -> + Text("- ${item.text}") + item.made_by?.let { madeBy -> + Text("decided by $madeBy", style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun StringListSection(label: String, items: List) { + Text(label, style = MaterialTheme.typography.titleSmall) + if (items.isEmpty()) { + Text("None recorded", color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + items.forEach { item -> Text("- $item") } +} + +@Composable +private fun StatusDot(status: RuntimeStatus) { + val color = when (status) { + RuntimeStatus.Ready -> Color(0xFF06B6D4) + RuntimeStatus.Unavailable -> Color(0xFFEF4444) + RuntimeStatus.Starting -> Color.Gray + } + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(color), + ) +} + +private fun statusLabel(status: RuntimeStatus): String = when (status) { + RuntimeStatus.Ready -> "Ready" + RuntimeStatus.Unavailable -> "Unavailable" + RuntimeStatus.Starting -> "Starting..." +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/SettingsScreen.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/SettingsScreen.kt new file mode 100644 index 0000000..928d6ac --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/SettingsScreen.kt @@ -0,0 +1,56 @@ +package com.traverseframework.meetingnotes.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.traverseframework.meetingnotes.RuntimeSettings +import kotlinx.coroutines.launch + +@Composable +fun SettingsScreen( + settings: RuntimeSettings, + currentWorkspace: String, + onBack: () -> Unit, +) { + var workspace by remember(currentWorkspace) { mutableStateOf(currentWorkspace) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Embedded runtime settings") + Text("Runtime mode is Embedded - no HTTP sidecar URL.") + OutlinedTextField( + value = workspace, + onValueChange = { workspace = it }, + label = { Text("Workspace") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + scope.launch { + settings.setWorkspace(workspace.trim()) + onBack() + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Save") + } + } +} diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/Theme.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/Theme.kt new file mode 100644 index 0000000..312b369 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/Theme.kt @@ -0,0 +1,13 @@ +package com.traverseframework.meetingnotes.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +@Composable +fun MeetingNotesTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = lightColorScheme(), + content = content, + ) +} diff --git a/apps/meeting-notes/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/meeting-notes/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..99310e9 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/meeting-notes/android-compose/app/src/main/res/values/colors.xml b/apps/meeting-notes/android-compose/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..27c0ce3 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #1E1B4B + #8B5CF6 + diff --git a/apps/meeting-notes/android-compose/app/src/main/res/values/strings.xml b/apps/meeting-notes/android-compose/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1009c08 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + meeting-notes + diff --git a/apps/meeting-notes/android-compose/app/src/main/res/values/themes.xml b/apps/meeting-notes/android-compose/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bf431c0 --- /dev/null +++ b/apps/meeting-notes/android-compose/app/src/main/res/values/themes.xml @@ -0,0 +1,3 @@ + +