diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 1f22aeb5e..35aca706a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'packages/**' + - '!packages/sdk-swift/**' - 'scripts/e2e-test.sh' - 'scripts/e2e-sdk-lifecycle.mjs' - 'package.json' @@ -13,6 +14,7 @@ on: branches: [main] paths: - 'packages/**' + - '!packages/sdk-swift/**' - 'scripts/e2e-test.sh' - 'scripts/e2e-sdk-lifecycle.mjs' - 'package.json' diff --git a/.github/workflows/node-compat.yml b/.github/workflows/node-compat.yml index bd48bd6f5..8cf397e02 100644 --- a/.github/workflows/node-compat.yml +++ b/.github/workflows/node-compat.yml @@ -12,10 +12,11 @@ concurrency: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} steps: - id: scope uses: actions/github-script@v7 @@ -42,13 +43,15 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); install-test: name: Install Test (Node ${{ matrix.node-version }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest strategy: matrix: @@ -104,7 +107,7 @@ jobs: fresh-install: name: Fresh Install (Node ${{ matrix.node-version }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/package-validation.yml b/.github/workflows/package-validation.yml index c1540be38..9ca810689 100644 --- a/.github/workflows/package-validation.yml +++ b/.github/workflows/package-validation.yml @@ -15,10 +15,11 @@ env: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} steps: - id: scope uses: actions/github-script@v7 @@ -45,13 +46,15 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); validate: name: Build & Validate needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest env: NPM_CONFIG_FUND: false @@ -228,7 +231,7 @@ jobs: publish-fresh-install-build: name: Publish Fresh Install Build needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest env: NPM_CONFIG_FUND: false @@ -262,7 +265,7 @@ jobs: standalone-macos-smoke: name: Standalone macOS Smoke needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: macos-latest env: NPM_CONFIG_FUND: false diff --git a/.github/workflows/relay-cleanroom-hardening.yml b/.github/workflows/relay-cleanroom-hardening.yml index 02e32698f..b7ffd3112 100644 --- a/.github/workflows/relay-cleanroom-hardening.yml +++ b/.github/workflows/relay-cleanroom-hardening.yml @@ -6,6 +6,7 @@ on: paths: - 'install.sh' - 'packages/**' + - '!packages/sdk-swift/**' - 'workflows/relay-e2e-meta-workflow.ts' - 'workflows/relay-clean-room-e2e-validation.ts' - 'scripts/run-relay-cleanroom-ci.sh' diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 5903d40e9..fa6ee9dd5 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -11,10 +11,11 @@ env: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} steps: - id: scope uses: actions/github-script@v7 @@ -41,13 +42,15 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); rust-test: name: Rust Tests (${{ matrix.os }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -64,7 +67,7 @@ jobs: rust-cross-compile: name: Cross-compile check (${{ matrix.target }}) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -98,7 +101,7 @@ jobs: rust-clippy: name: Clippy needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -113,7 +116,7 @@ jobs: rust-fmt: name: Format needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -125,7 +128,7 @@ jobs: sdk-check: name: SDK TypeScript Check needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 544c779e0..936636959 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,12 @@ env: jobs: changes: - name: Detect web-only change set + name: Detect change scope runs-on: ubuntu-latest outputs: web_only: ${{ steps.scope.outputs.web_only }} + sdk_swift_only: ${{ steps.scope.outputs.sdk_swift_only }} + sdk_swift_changed: ${{ steps.scope.outputs.sdk_swift_changed }} steps: - id: scope uses: actions/github-script@v7 @@ -45,12 +47,16 @@ jobs: } const webOnly = files.length > 0 && files.every((file) => file.startsWith('web/')); + const sdkSwiftOnly = files.length > 0 && files.every((file) => file.startsWith('packages/sdk-swift/')); + const sdkSwiftChanged = files.some((file) => file.startsWith('packages/sdk-swift/')); core.info(`Changed files (${files.length}): ${files.join(', ')}`); core.setOutput('web_only', webOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_only', sdkSwiftOnly ? 'true' : 'false'); + core.setOutput('sdk_swift_changed', sdkSwiftChanged ? 'true' : 'false'); test: needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: matrix: @@ -80,7 +86,7 @@ jobs: coverage: name: Coverage (upload) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -108,7 +114,7 @@ jobs: lint: needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ubuntu-latest steps: - name: Checkout repository @@ -135,7 +141,7 @@ jobs: rust-test: name: Rust Tests (agent-relay-broker) needs: changes - if: needs.changes.outputs.web_only != 'true' + if: needs.changes.outputs.web_only != 'true' && needs.changes.outputs.sdk_swift_only != 'true' runs-on: ${{ matrix.os }} strategy: matrix: @@ -162,3 +168,24 @@ jobs: - name: Run Clippy lints run: cargo clippy -- -D warnings + + swift-test: + name: Swift SDK Tests + needs: changes + if: needs.changes.outputs.sdk_swift_changed == 'true' + runs-on: macos-latest + defaults: + run: + working-directory: packages/sdk-swift + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Show Swift version + run: swift --version + + - name: Build + run: swift build + + - name: Test + run: swift test diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8d69dc0..e3a130c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `@agent-relay/sdk` swaps `@agentworkforce/harness-kit` + `@agentworkforce/workload-router` for `@agentworkforce/persona-kit@^3`. The persona tier system, the `tier` option on `spawnPersona`, the legacy relay-side `PersonaFile` / `PersonaTier` / `PersonaTierSpec` / `ResolvedPersona` / `PersonaSpawnSpec` / `MaterializedConfigFile` types, and the `buildPersonaSpawnSpec` / `materializePersonaConfigFiles` / `restorePersonaConfigFiles` helpers are removed. `loadPersona` now returns the canonical `PersonaSpec`, and `spawnPersona({ persona })` takes a `PersonaSpec` instead of a resolved persona. - `agent-relay-broker`'s public Rust protocol types now require typed ID newtypes (`WorkerName`, `DeliveryId`, `EventId`, `WorkspaceId`, `WorkspaceAlias`, `ThreadId`, `AgentId`, `RequestId`, `ChannelName`, `MessageTarget`) on every protocol struct and enum variant in `protocol.rs`, `types.rs`, and `listen_api.rs::ListenApiRequest`. The new wrappers live in `crates/broker/src/lib.rs` under `pub mod ids`. JSON wire format is unchanged because every wrapper is `#[serde(transparent)]`, so the broker ↔ SDK channel and on-disk persisted state remain byte-compatible. - `agent-relay spawn` and SDK spawn calls now return harness `sessionId` metadata for resumable Claude and Codex PTY sessions. +- `sdk-swift`: renamed the broker client class `RelayCast` → `AgentRelayClient`. ### Migration Guidance @@ -29,10 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Callers that previously used `spawnPersona` to "just launch the harness" — without persona-kit's skill / mount / sidecar side effects — should use `AgentRelay.getPersonaSpawnPlan(id)` to inspect the plan and call `spawnPty` with the plan's `cli` + `args` themselves. - Downstream Rust callers must construct identifiers via `relay_broker::ids::{WorkerName, DeliveryId, EventId, MessageTarget, …}` instead of `String`. Each newtype impls `From` / `From<&str>` and `Deref`, so most string-handling code keeps compiling; only construction sites (`HashMap` keys, struct literals, channel sends) need updates. - Replace ad-hoc target discrimination (`target.starts_with('#')`, `target == "thread"`) with `MessageTarget::kind()` and match on `MessageTargetKind::{Channel, Thread, DirectMessage, Conversation, Worker}`. +- `sdk-swift`: replace `RelayCast(apiKey:baseURL:)` with `AgentRelayClient(apiKey:baseURL:)`. The public API surface is otherwise unchanged. ### Fixed - `web`: PR preview SST deploys use and comment the generated CloudFront URL and AWS's managed disabled cache policy instead of creating per-preview Cloudflare DNS records, ACM certificates, and custom CloudFront cache policies. +- `sdk-swift`: broker client now connects to the v7 broker's `/ws` event stream without a legacy `hello`/`hello_ack` handshake and routes `spawnAgent`, `releaseAgent`, channel `post`, and agent `dm` through the broker's HTTP API (`/api/spawn`, `/api/spawned/{name}`, `/api/send`). ### Security diff --git a/packages/sdk-swift/README.md b/packages/sdk-swift/README.md index 80726b631..c005cb3db 100644 --- a/packages/sdk-swift/README.md +++ b/packages/sdk-swift/README.md @@ -1,6 +1,7 @@ # AgentRelaySDK -Native Swift SDK for the Agent Relay broker. +Native Swift SDK for `agent-relay-broker`. Talks to the broker over its `/ws` +event stream and `/api/*` HTTP endpoints. ## Installation @@ -19,8 +20,10 @@ Then depend on `AgentRelaySDK`. ```swift import AgentRelaySDK -let relay = RelayCast(apiKey: "rk_live_...") -let channel = relay.channel("wf-my-workflow") +// Point at a local broker started with `agent-relay up` (defaults to +// http://localhost:3889) or pass `baseURL:` for a remote broker. +let client = AgentRelayClient(apiKey: "rk_live_...") +let channel = client.channel("wf-my-workflow") try await channel.subscribe() try await channel.post("Hello from Swift") @@ -31,8 +34,10 @@ for await event in channel.events { ## API -- `RelayCast(apiKey:baseURL:)` +- `AgentRelayClient(apiKey:baseURL:)` — broker client - `channel(_:) -> Channel` +- `spawnAgent(_:initialTask:skipRelayPrompt:)` +- `releaseAgent(name:reason:)` - `registerOrRotate(name:)` - `AgentRegistration.asClient()` - `AgentClient.post(to:message:)` diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift similarity index 63% rename from packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift rename to packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift index f62f3c430..6756b0849 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/AgentRelayClient.swift @@ -52,21 +52,20 @@ public struct AgentRegistration: Sendable { actor RelayCore { let apiKey: String let transport: RelayTransport + let http: RelayHTTP let encoder = JSONEncoder() let decoder = JSONDecoder() - private var handshakeInFlight = false - private var handshakeGeneration = 0 - private var handshakeContinuations: [CheckedContinuation] = [] private var routerTask: Task? private var channelContinuations: [String: [AsyncStream.Continuation]] = [:] private var brokerEventContinuations: [AsyncStream.Continuation] = [] private var inboundMessageContinuations: [AsyncStream.Continuation] = [] private var connectionStateContinuations: [AsyncStream.Continuation] = [] - init(apiKey: String, transport: RelayTransport) { + init(apiKey: String, transport: RelayTransport, http: RelayHTTP) { self.apiKey = apiKey self.transport = transport + self.http = http } func configureTransportCallbacks() async { @@ -75,32 +74,17 @@ actor RelayCore { } } + /// Open the read-only event WebSocket if it's not already connected. func ensureConnected() async throws { if routerTask == nil || routerTask?.isCancelled == true { routerTask = Task { [weak self] in await self?.routeFrames() } } - if handshakeInFlight { - return try await waitForHandshake() - } - handshakeInFlight = true - handshakeGeneration &+= 1 try await transport.connect() - try await send(.hello(HelloPayload(clientName: "AgentRelaySDK.Swift", clientVersion: "0.1.0", apiKey: apiKey))) - try await waitForHandshake() + notifyConnectionState(.connected) } func transportDidConnect() async { - if handshakeInFlight { - finishHandshake(with: RelayError.connectionFailed("Transport reconnected before previous handshake completed")) - } - handshakeInFlight = true - handshakeGeneration &+= 1 notifyConnectionState(.connected) - do { - try await send(.hello(HelloPayload(clientName: "AgentRelaySDK.Swift", clientVersion: "0.1.0", apiKey: apiKey))) - } catch { - finishHandshake(with: error) - } } func registerChannelContinuation(_ continuation: AsyncStream.Continuation, for channel: String) { @@ -120,23 +104,29 @@ actor RelayCore { } func sendChannelPost(channel: String, text: String) async throws { - try await ensureConnected() - try await send(.sendMessage(SendMessagePayload(to: channel, text: text, from: nil, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil))) + try await sendMessageHTTP(SendMessagePayload(to: channel, text: text, from: nil, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil)) } func sendAgentMessage(from agentName: String, to target: String, text: String) async throws { - try await ensureConnected() - try await send(.sendMessage(SendMessagePayload(to: target, text: text, from: agentName, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil))) + try await sendMessageHTTP(SendMessagePayload(to: target, text: text, from: agentName, threadId: nil, workspaceId: nil, workspaceAlias: nil, priority: nil, data: nil)) } func spawnAgent(_ spec: AgentSpec, initialTask: String? = nil, skipRelayPrompt: Bool? = nil) async throws { - try await ensureConnected() - try await send(.spawnAgent(SpawnAgentPayload(agent: spec, initialTask: initialTask, skipRelayPrompt: skipRelayPrompt))) + let body = try encodeJSON(SpawnRequestBody(spec: spec, task: initialTask, skipRelayPrompt: skipRelayPrompt)) + _ = try await http.post(path: "/api/spawn", body: body) } func releaseAgent(name: String, reason: String? = nil) async throws { - try await ensureConnected() - try await send(.releaseAgent(ReleaseAgentPayload(name: name, reason: reason))) + var pathSegmentAllowed = CharacterSet.urlPathAllowed + pathSegmentAllowed.remove(charactersIn: "/") + let escaped = name.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) ?? name + let body: Data? + if let reason { + body = try encodeJSON(["reason": reason]) + } else { + body = nil + } + _ = try await http.delete(path: "/api/spawned/\(escaped)", body: body) } func registerOrRotate(name: String) async throws -> AgentRegistration { @@ -149,12 +139,6 @@ actor RelayCore { func disconnect() async { routerTask?.cancel() routerTask = nil - handshakeInFlight = false - let pendingHandshakes = handshakeContinuations - handshakeContinuations.removeAll() - for continuation in pendingHandshakes { - continuation.resume(throwing: RelayError.notConnected) - } await transport.disconnect() notifyConnectionState(.disconnected) // Finish all event stream continuations @@ -170,55 +154,19 @@ actor RelayCore { connectionStateContinuations.removeAll() } - private func send(_ message: OutboundMessage) async throws { + private func sendMessageHTTP(_ payload: SendMessagePayload) async throws { + let body = try encodeJSON(payload) + _ = try await http.post(path: "/api/send", body: body) + } + + private func encodeJSON(_ value: T) throws -> Data { do { - let data = try encoder.encode(message) - try await transport.send(data) - } catch let error as RelayTransport.TransportError { - switch error { - case .notConnected: throw RelayError.notConnected - case .connectionFailed(let message), .sendFailed(let message): throw RelayError.connectionFailed(message) - case .invalidResponse: throw RelayError.connectionFailed("Invalid response") - } + return try encoder.encode(value) } catch { throw RelayError.encodingFailed(String(describing: error)) } } - private func waitForHandshake() async throws { - let generation = handshakeGeneration - try await withCheckedThrowingContinuation { continuation in - handshakeContinuations.append(continuation) - Task { [weak self] in - try? await Task.sleep(for: .seconds(10)) - await self?.failHandshakeIfPending(generation: generation, with: RelayError.timeout("Timed out waiting for hello_ack")) - } - } - } - - private func finishHandshake() { - handshakeInFlight = false - let continuations = handshakeContinuations - handshakeContinuations.removeAll() - for continuation in continuations { - continuation.resume(returning: ()) - } - } - - private func finishHandshake(with error: Error) { - handshakeInFlight = false - let continuations = handshakeContinuations - handshakeContinuations.removeAll() - for continuation in continuations { - continuation.resume(throwing: error) - } - } - - private func failHandshakeIfPending(generation: Int, with error: Error) { - guard handshakeInFlight, handshakeGeneration == generation else { return } - finishHandshake(with: error) - } - private func notifyConnectionState(_ state: ConnectionStateChange) { for continuation in connectionStateContinuations { continuation.yield(state) @@ -227,51 +175,83 @@ actor RelayCore { private func routeFrames() async { for await data in transport.inbound { - do { - let inbound = try decoder.decode(InboundMessage.self, from: data) + guard let event = try? decoder.decode(BrokerEvent.self, from: data) else { + continue + } - // Notify all raw inbound message subscribers - for continuation in inboundMessageContinuations { - continuation.yield(inbound) - } + for continuation in inboundMessageContinuations { + continuation.yield(.event(event)) + } + for continuation in brokerEventContinuations { + continuation.yield(event) + } - switch inbound { - case .helloAck: - finishHandshake() - case .event(let event): - // Notify all broker event subscribers - for continuation in brokerEventContinuations { - continuation.yield(event) - } - - // Route relay_inbound events to channel subscribers - if case .relayInbound(let relayEvent) = event { - let message = RelayChannelEvent(from: relayEvent.from, body: relayEvent.body, threadId: relayEvent.threadId) - for continuation in channelContinuations[relayEvent.target] ?? [] { - continuation.yield(message) - } - } - case .deliverRelay(let delivery): - // Route relay deliveries to channel subscribers as RelayChannelEvents - let message = RelayChannelEvent(from: delivery.from, body: delivery.body, threadId: delivery.threadId) - for continuation in channelContinuations[delivery.target] ?? [] { - continuation.yield(message) - } - case .error(let error): - finishHandshake(with: RelayError.protocolError(code: error.code, message: error.message, retryable: error.retryable)) - default: - break + if case .relayInbound(let relayEvent) = event { + let message = RelayChannelEvent(from: relayEvent.from, body: relayEvent.body, threadId: relayEvent.threadId) + for continuation in channelContinuations[relayEvent.target] ?? [] { + continuation.yield(message) } - } catch { - continue } } - // Transport stream ended (disconnection) notifyConnectionState(.disconnected) } } -public final class RelayCast: @unchecked Sendable { +/// Body shape for `POST /api/spawn` — flattens the AgentSpec fields onto the +/// request as the broker expects (name, cli, runtime, args, channels, model, +/// cwd, team, etc.) plus optional `task` and `skipRelayPrompt`. +private struct SpawnRequestBody: Encodable { + let spec: AgentSpec + let task: String? + let skipRelayPrompt: Bool? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: SpawnCodingKeys.self) + try container.encode(spec.name, forKey: .name) + try container.encode(spec.runtime.rawValue, forKey: .runtime) + if let provider = spec.provider { + try container.encode(provider.rawValue, forKey: .cli) + } else if let cli = spec.cli { + try container.encode(cli, forKey: .cli) + } + if let model = spec.model { + try container.encode(model, forKey: .model) + } + try container.encode(spec.args ?? [], forKey: .args) + try container.encode(spec.channels ?? [], forKey: .channels) + if let cwd = spec.cwd { + try container.encode(cwd, forKey: .cwd) + } + if let team = spec.team { + try container.encode(team, forKey: .team) + } + if let shadowOf = spec.shadowOf { + try container.encode(shadowOf, forKey: .shadowOf) + } + if let shadowMode = spec.shadowMode { + try container.encode(shadowMode, forKey: .shadowMode) + } + if let restartPolicy = spec.restartPolicy { + try container.encode(restartPolicy, forKey: .restartPolicy) + } + if let task { + try container.encode(task, forKey: .task) + } + if let skipRelayPrompt { + try container.encode(skipRelayPrompt, forKey: .skipRelayPrompt) + } + } + + enum SpawnCodingKeys: String, CodingKey { + case name, cli, runtime, model, args, channels, cwd, team, task + case shadowOf = "shadow_of" + case shadowMode = "shadow_mode" + case restartPolicy = "restart_policy" + case skipRelayPrompt = "skip_relay_prompt" + } +} + +public final class AgentRelayClient: @unchecked Sendable { private let core: RelayCore public let apiKey: String public let baseURL: URL @@ -281,7 +261,8 @@ public final class RelayCast: @unchecked Sendable { let resolved = Self.resolveBaseURL(from: baseURL) self.baseURL = resolved let transport = RelayTransport(baseURL: resolved, authToken: apiKey) - self.core = RelayCore(apiKey: apiKey, transport: transport) + let http = RelayHTTP(baseURL: resolved, apiKey: apiKey) + self.core = RelayCore(apiKey: apiKey, transport: transport, http: http) Task { await self.core.configureTransportCallbacks() } @@ -331,10 +312,6 @@ public final class RelayCast: @unchecked Sendable { } /// Stream of all raw inbound protocol messages. - /// - /// This is the lowest-level event stream, including hello_ack, ok, error, - /// event, deliver_relay, worker_stream, worker_exited, and pong frames. - /// Use this when you need full protocol visibility. public var inboundMessages: AsyncStream { AsyncStream { continuation in Task { await core.registerInboundMessageContinuation(continuation) } diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift new file mode 100644 index 000000000..af04dc808 --- /dev/null +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayHTTP.swift @@ -0,0 +1,109 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Minimal HTTP client for the broker REST API. +actor RelayHTTP { + private let baseURL: URL + private let apiKey: String + private let session: URLSession + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + return encoder + }() + private let decoder: JSONDecoder = JSONDecoder() + + init(baseURL: URL, apiKey: String, session: URLSession = .shared) { + self.baseURL = baseURL + self.apiKey = apiKey + self.session = session + } + + func post(path: String, body: Data?) async throws -> Data { + try await request(method: "POST", path: path, body: body) + } + + func delete(path: String, body: Data?) async throws -> Data { + try await request(method: "DELETE", path: path, body: body) + } + + func get(path: String) async throws -> Data { + try await request(method: "GET", path: path, body: nil) + } + + private func request(method: String, path: String, body: Data?) async throws -> Data { + guard let url = url(for: path) else { + throw RelayError.invalidBaseURL("Could not resolve URL for path \(path)") + } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue(apiKey, forHTTPHeaderField: "X-API-Key") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw RelayError.connectionFailed(String(describing: error)) + } + + guard let http = response as? HTTPURLResponse else { + throw RelayError.connectionFailed("Non-HTTP response from broker") + } + + if !(200..<300).contains(http.statusCode) { + let (code, message) = decodeErrorBody(data, fallbackStatus: http.statusCode) + throw RelayError.protocolError( + code: code, + message: message, + retryable: http.statusCode >= 500 + ) + } + + return data + } + + private func url(for path: String) -> URL? { + Self.resolveAPIURL(baseURL: baseURL, path: path) + } + + static func resolveAPIURL(baseURL: URL, path: String) -> URL? { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + if components.scheme == "ws" { components.scheme = "http" } + if components.scheme == "wss" { components.scheme = "https" } + + var basePath = components.path + while basePath.hasSuffix("/") { basePath = String(basePath.dropLast()) } + if basePath.hasSuffix("/v1/ws") { + basePath = String(basePath.dropLast("/v1/ws".count)) + } else if basePath.hasSuffix("/ws") { + basePath = String(basePath.dropLast("/ws".count)) + } + + let normalizedPath = path.hasPrefix("/") ? path : "/" + path + components.path = basePath + normalizedPath + components.query = nil + components.fragment = nil + return components.url + } + + private func decodeErrorBody(_ data: Data, fallbackStatus: Int) -> (code: String, message: String) { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let code = (json["code"] as? String) ?? "http_\(fallbackStatus)" + let message = + (json["message"] as? String) + ?? (json["error"] as? String) + ?? "HTTP \(fallbackStatus)" + return (code, message) + } + return ("http_\(fallbackStatus)", "HTTP \(fallbackStatus)") + } +} diff --git a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift index 3fef03e41..32b1837bd 100644 --- a/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift +++ b/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift @@ -99,20 +99,30 @@ public actor RelayTransport { } private func websocketRequest() -> URLRequest { + var request = URLRequest(url: Self.resolveWebSocketURL(baseURL: baseURL) ?? baseURL) + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue(authToken, forHTTPHeaderField: "X-API-Key") + return request + } + + static func resolveWebSocketURL(baseURL: URL) -> URL? { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) if components?.scheme == "http" { components?.scheme = "ws" } if components?.scheme == "https" { components?.scheme = "wss" } - if components?.path.isEmpty ?? true { components?.path = "/v1/ws" } - if !(components?.path.hasSuffix("/v1/ws") ?? false) && !(components?.path.hasSuffix("/ws") ?? false) { - components?.path = "/v1/ws" + + var path = components?.path ?? "" + while path.hasSuffix("/") { path = String(path.dropLast()) } + if path.hasSuffix("/v1/ws") { + path = String(path.dropLast("/v1/ws".count)) + } else if path.hasSuffix("/ws") { + path = String(path.dropLast("/ws".count)) } - let existingItems = components?.queryItems ?? [] - components?.queryItems = existingItems + [URLQueryItem(name: "token", value: authToken)] + components?.path = path + "/ws" - var request = URLRequest(url: components?.url ?? baseURL) - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue(authToken, forHTTPHeaderField: "X-API-Key") - return request + let filteredQuery = components?.queryItems?.filter { $0.name != "token" } + components?.queryItems = (filteredQuery?.isEmpty == false) ? filteredQuery : nil + + return components?.url } private func startReceiveLoop() { diff --git a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift index 12acc83bd..662e4c960 100644 --- a/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +++ b/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift @@ -2,14 +2,102 @@ import XCTest @testable import AgentRelaySDK final class AgentRelaySDKTests: XCTestCase { - func testRelayCastInit() { - let relay = RelayCast(apiKey: "rk_test_key") - XCTAssertEqual(relay.apiKey, "rk_test_key") + func testAgentRelayClientInit() { + let client = AgentRelayClient(apiKey: "rk_test_key") + XCTAssertEqual(client.apiKey, "rk_test_key") } func testChannelCreation() { - let relay = RelayCast(apiKey: "rk_test_key") - let channel = relay.channel("test-channel") + let client = AgentRelayClient(apiKey: "rk_test_key") + let channel = client.channel("test-channel") XCTAssertEqual(channel.name, "test-channel") } + + func testDefaultLocalBrokerURL() { + let client = AgentRelayClient(apiKey: "rk_test_key") + XCTAssertEqual(client.baseURL.host, "localhost") + XCTAssertEqual(client.baseURL.port, 3889) + } + + /// The broker emits each event as a bare `{kind: ...}` JSON object on `/ws`. + func testBrokerEventDecodesBarePayload() throws { + let json = """ + { + "kind": "relay_inbound", + "event_id": "evt_1", + "from": "alice", + "target": "wf-test", + "body": "hello", + "thread_id": "t1" + } + """.data(using: .utf8)! + let event = try JSONDecoder().decode(BrokerEvent.self, from: json) + if case .relayInbound(let inbound) = event { + XCTAssertEqual(inbound.from, "alice") + XCTAssertEqual(inbound.target, "wf-test") + XCTAssertEqual(inbound.body, "hello") + XCTAssertEqual(inbound.threadId, "t1") + } else { + XCTFail("expected relayInbound, got \(event)") + } + } + + // MARK: - WebSocket URL resolution + + func testWebSocketURLAppendsWS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLUpgradesHTTPS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "https://broker.example.com")!) + XCTAssertEqual(url?.absoluteString, "wss://broker.example.com/ws") + } + + func testWebSocketURLNormalizesTrailingSlash() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/ws/")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLDoesNotDoubleAppendWS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/ws")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLRewritesLegacyV1WS() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/v1/ws")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + func testWebSocketURLStripsLegacyTokenQuery() { + let url = RelayTransport.resolveWebSocketURL(baseURL: URL(string: "http://localhost:3889/?token=secret")!) + XCTAssertEqual(url?.absoluteString, "ws://localhost:3889/ws") + } + + // MARK: - REST API URL resolution + + func testAPIURLAppendsPath() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "http://localhost:3889")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLStripsWSBasePath() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "http://localhost:3889/ws")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLStripsLegacyV1WSBasePath() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "http://localhost:3889/v1/ws")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLDowngradesWSScheme() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "ws://localhost:3889/ws")!, path: "/api/send") + XCTAssertEqual(url?.absoluteString, "http://localhost:3889/api/send") + } + + func testAPIURLDowngradesWSSScheme() { + let url = RelayHTTP.resolveAPIURL(baseURL: URL(string: "wss://broker.example.com/ws")!, path: "/api/spawn") + XCTAssertEqual(url?.absoluteString, "https://broker.example.com/api/spawn") + } }