From 55ca715f1b7cad237d8f072a3b44afc0536457a2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 2 Oct 2026 09:34:04 +0200 Subject: [PATCH 1/3] test: add registerEvent to the Swift SDK (#126) --- packages/native/fixtures/README.md | 17 +- .../fixtures/event-registry-frames.json | 154 +++++++++++++++ .../AppductEventRegistryTests.swift | 185 ++++++++++++++++++ .../FixturesConformanceTests.swift | 23 +++ .../Tests/AppductCoreTests/TestSupport.swift | 6 +- 5 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 packages/native/fixtures/event-registry-frames.json create mode 100644 packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift diff --git a/packages/native/fixtures/README.md b/packages/native/fixtures/README.md index 87cc2c7f..bfa77c47 100644 --- a/packages/native/fixtures/README.md +++ b/packages/native/fixtures/README.md @@ -69,8 +69,21 @@ Array of `{ name, descriptor, valid }` covering `@appduct/shared`'s `isEventDesc (`docs/PROTOCOL.md` §5a): a name is any non-empty string up to 4096 UTF-16 code units (dotted names and names with spaces are valid, unlike a tool name), a description is 1 to 4096 characters, and `payload_schema` must be a JSON object if present (rejecting a string, an array and `null`). The 4096 limit is pinned in UTF-16 code units: 2048 non-BMP characters (😀) pass, 2049 fail. -Currently asserted by the TypeScript suite only; the Swift and Kotlin suites join it with their -`registerEvent` slices. +Asserted by the TypeScript and Swift suites; the Kotlin suite joins it with its `registerEvent` +slice. + +### `event-registry-frames.json` + +Array of `{ name, sessionId, declaredBeforeAck, afterAck, frames }` pinning the exact +`event_registry_snapshot` / `event_registry_delta` frames (`docs/PROTOCOL.md` §5a) an SDK sends. +Each case is a scenario run through the SDK's public API against a fake transport: declare every +descriptor in `declaredBeforeAck` (valid `EventDescriptor`s in wire form), connect, deliver a +`session_ack` that carries `"event_registry": true`, then apply each `afterAck` step in order +(`{ "op": "register", "event": }` or `{ "op": "remove", "name": }`, where +`remove` is the disposer of the event registered under that name). `frames` is the complete, ordered +list of `event_registry_*` frames the SDK must have sent, compared as JSON (key order does not +matter); other frames, such as `tool_registry_snapshot`, are ignored. Covers the snapshot of +several events, the empty snapshot, an upsert delta and a remove delta. ### `tool-descriptors.json` diff --git a/packages/native/fixtures/event-registry-frames.json b/packages/native/fixtures/event-registry-frames.json new file mode 100644 index 00000000..b7d2a308 --- /dev/null +++ b/packages/native/fixtures/event-registry-frames.json @@ -0,0 +1,154 @@ +[ + { + "name": "snapshot-lists-every-declared-event-in-declaration-order", + "sessionId": "XzAERP54_Goh74hZ", + "declaredBeforeAck": [ + { + "name": "checkout_completed", + "description": "Fired once an order finishes checkout.", + "payload_schema": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": [ + "orderId" + ] + } + }, + { + "name": "cart.item_added", + "description": "An item went into the cart." + } + ], + "afterAck": [], + "frames": [ + { + "type": "event_registry_snapshot", + "session_id": "XzAERP54_Goh74hZ", + "events": [ + { + "name": "checkout_completed", + "description": "Fired once an order finishes checkout.", + "payload_schema": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": [ + "orderId" + ] + } + }, + { + "name": "cart.item_added", + "description": "An item went into the cart." + } + ] + } + ] + }, + { + "name": "snapshot-with-no-declared-events-is-empty", + "sessionId": "XzAERP54_Goh74hZ", + "declaredBeforeAck": [], + "afterAck": [], + "frames": [ + { + "type": "event_registry_snapshot", + "session_id": "XzAERP54_Goh74hZ", + "events": [] + } + ] + }, + { + "name": "declaring-after-the-ack-sends-an-upsert-delta", + "sessionId": "XzAERP54_Goh74hZ", + "declaredBeforeAck": [], + "afterAck": [ + { + "op": "register", + "event": { + "name": "cart.item_added", + "description": "An item went into the cart." + } + } + ], + "frames": [ + { + "type": "event_registry_snapshot", + "session_id": "XzAERP54_Goh74hZ", + "events": [] + }, + { + "type": "event_registry_delta", + "session_id": "XzAERP54_Goh74hZ", + "operation": "upsert", + "event": { + "name": "cart.item_added", + "description": "An item went into the cart." + } + } + ] + }, + { + "name": "disposing-sends-a-remove-delta", + "sessionId": "XzAERP54_Goh74hZ", + "declaredBeforeAck": [ + { + "name": "checkout_completed", + "description": "Fired once an order finishes checkout.", + "payload_schema": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": [ + "orderId" + ] + } + } + ], + "afterAck": [ + { + "op": "remove", + "name": "checkout_completed" + } + ], + "frames": [ + { + "type": "event_registry_snapshot", + "session_id": "XzAERP54_Goh74hZ", + "events": [ + { + "name": "checkout_completed", + "description": "Fired once an order finishes checkout.", + "payload_schema": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": [ + "orderId" + ] + } + } + ] + }, + { + "type": "event_registry_delta", + "session_id": "XzAERP54_Goh74hZ", + "operation": "remove", + "name": "checkout_completed" + } + ] + } +] diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift new file mode 100644 index 00000000..dcd93c01 --- /dev/null +++ b/packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift @@ -0,0 +1,185 @@ +import XCTest +@testable import AppductCore + +/// `registerEvent` (issue #126): the event registry frames an SDK sends (`docs/PROTOCOL.md` §5a), +/// observed on a fake transport. The frame scenarios come from +/// `packages/native/fixtures/event-registry-frames.json`, shared with the Kotlin suite. +final class AppductEventRegistryTests: XCTestCase { + override func setUp() { + super.setUp() + AppductProcessResumeLeaseStore.shared.resetForTests() + } + + private func makeClient(timers: FakeClientTimers = FakeClientTimers()) -> (AppductClient, FakeTransportSession) { + let transport = FakeTransportSession() + let client = AppductClient( + transport: transport, + timers: timers, + requirePrivateIp: true, + foregroundObserver: NeverBackgroundedObserver() + ) + return (client, transport) + } + + private func connect( + _ client: AppductClient, + _ transport: FakeTransportSession, + sessionId: String = "session-1", + eventRegistry: Bool + ) async throws { + let input = AppductConnectInput( + ip: "192.168.1.10", + port: 8_443, + sessionId: sessionId, + token: "claim-token", + expiresAt: 9_999_999_999 + ) + let connectTask = Task { try await client.connect(input) } + try await waitUntil("the client started its transport handshake") { transport.isWired && transport.connectCallCount >= 1 } + transport.simulateAck(sessionId: sessionId, eventRegistry: eventRegistry) + try await connectTask.value + } + + /// The `event_registry_*` frames sent so far, parsed. + private func eventFrames(_ transport: FakeTransportSession) -> [JSONValue] { + transport.sentMessages.compactMap { text in + guard let value = try? JSONValue.parse(text), value["type"]?.stringValue?.hasPrefix("event_registry_") == true else { + return nil + } + return value + } + } + + private func descriptor(_ name: String, description: String = "d") -> EventDescriptor { + EventDescriptor(name: name, description: description) + } + + // MARK: fixture + + func testFramesMatchTheSharedFixture() async throws { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("fixtures/event-registry-frames.json") + let cases = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as! [[String: Any]] + XCTAssertGreaterThan(cases.count, 0) + + for scenario in cases { + AppductProcessResumeLeaseStore.shared.resetForTests() + let name = scenario["name"] as! String + let sessionId = scenario["sessionId"] as! String + let (client, transport) = makeClient() + + var registrations: [String: EventRegistration] = [:] + for raw in scenario["declaredBeforeAck"] as! [Any] { + let event = try parseEventDescriptor(JSONValue.from(foundation: raw)) + registrations[event.name] = try client.registerEvent(event) + } + try await connect(client, transport, sessionId: sessionId, eventRegistry: true) + + let expected = (scenario["frames"] as! [Any]).map { JSONValue.from(foundation: $0) } + for step in scenario["afterAck"] as! [[String: Any]] { + switch step["op"] as! String { + case "register": + let event = try parseEventDescriptor(JSONValue.from(foundation: step["event"]!)) + registrations[event.name] = try client.registerEvent(event) + case "remove": + registrations[step["name"] as! String]!.remove() + default: + XCTFail("\(name): unknown step") + } + } + + try await waitUntil("\(name): the frames reached the wire") { self.eventFrames(transport).count >= expected.count } + await allowQueuedWorkToRun() + XCTAssertEqual(eventFrames(transport), expected, name) + } + } + + // MARK: behaviour + + func testAckWithoutTheFlagSendsNoEventFrames() async throws { + let (client, transport) = makeClient() + let registration = try client.registerEvent(descriptor("a")) + try await connect(client, transport, eventRegistry: false) + try client.registerEvent(descriptor("b")) + registration.remove() + try await waitUntil("the tool snapshot reached the wire") { + transport.sentMessages.contains { $0.contains("tool_registry_snapshot") } + } + await allowQueuedWorkToRun() + + XCTAssertEqual(eventFrames(transport), []) + } + + func testEveryAckCarryingTheFlagIsFollowedByASnapshotIncludingAfterResume() async throws { + let timers = FakeClientTimers(random: 0) + let (client, transport) = makeClient(timers: timers) + try client.registerEvent(descriptor("a")) + try await connect(client, transport, eventRegistry: true) + try await waitUntil("the first snapshot reached the wire") { self.eventFrames(transport).count == 1 } + + transport.simulateClose(code: 1_006, reason: nil) + try await waitUntil("the client is reconnecting") { await client.state == .reconnecting } + try client.registerEvent(descriptor("b")) + timers.advance(byMs: AppductBackoff.capMs) + try await waitUntil("the resume handshake started") { transport.isWired && transport.connectCallCount >= 2 } + transport.simulateAck(sessionId: "session-1", resumeToken: "resume-2", eventRegistry: true) + try await waitUntil("the snapshot after the resume reached the wire") { self.eventFrames(transport).count == 2 } + + let second = eventFrames(transport)[1] + XCTAssertEqual(second["type"]?.stringValue, "event_registry_snapshot") + XCTAssertEqual(second["events"]?.arrayValue?.compactMap { $0["name"]?.stringValue }, ["a", "b"]) + } + + func testRemovingTwiceSendsOneRemoveDelta() async throws { + let (client, transport) = makeClient() + let registration = try client.registerEvent(descriptor("a")) + try await connect(client, transport, eventRegistry: true) + try await waitUntil("the snapshot reached the wire") { self.eventFrames(transport).count == 1 } + + registration.remove() + registration.remove() + try await waitUntil("the remove delta reached the wire") { self.eventFrames(transport).count == 2 } + await allowQueuedWorkToRun() + + XCTAssertEqual(eventFrames(transport).count, 2) + } + + func testRegisterEventRejectsAnInvalidDescriptorAndDeclaresNothing() async throws { + let (client, transport) = makeClient() + XCTAssertThrowsError(try client.registerEvent(EventDescriptor(name: "", description: "d"))) + XCTAssertThrowsError(try client.registerEvent(EventDescriptor(name: "a", description: ""))) + try await connect(client, transport, eventRegistry: true) + try await waitUntil("the snapshot reached the wire") { self.eventFrames(transport).count == 1 } + + XCTAssertEqual(eventFrames(transport)[0]["events"]?.arrayValue?.count, 0) + } + + func testRegisterEventAcceptsADottedNameAndAFullLengthName() throws { + let (client, _) = makeClient() + XCTAssertNoThrow(try client.registerEvent(descriptor("cart.item_added"))) + XCTAssertNoThrow(try client.registerEvent(descriptor(String(repeating: "a", count: 4_096)))) + XCTAssertThrowsError(try client.registerEvent(descriptor(String(repeating: "a", count: 4_097)))) + } + + func testFacadeRegisterEventConvertsThePayloadSchemaAndDisposes() async throws { + let (client, transport) = makeClient() + let facade = Appduct(client: client) + let registration = try facade.registerEvent( + name: "checkout_completed", + description: "Fired once an order finishes checkout.", + payloadSchema: ["type": "object", "properties": ["orderId": ["type": "string"]]] + ) + try await connect(client, transport, eventRegistry: true) + try await waitUntil("the snapshot reached the wire") { self.eventFrames(transport).count == 1 } + let schema = eventFrames(transport)[0]["events"]?.arrayValue?.first?["payload_schema"] + XCTAssertEqual(schema?["type"]?.stringValue, "object") + + registration.remove() + try await waitUntil("the remove delta reached the wire") { self.eventFrames(transport).count == 2 } + } +} + +private extension JSONValue { + subscript(key: String) -> JSONValue? { objectValue?[key] } +} diff --git a/packages/native/ios/Tests/AppductCoreTests/FixturesConformanceTests.swift b/packages/native/ios/Tests/AppductCoreTests/FixturesConformanceTests.swift index 913612ea..6fad8cae 100644 --- a/packages/native/ios/Tests/AppductCoreTests/FixturesConformanceTests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/FixturesConformanceTests.swift @@ -116,6 +116,29 @@ final class FixturesConformanceTests: XCTestCase { } } + // MARK: - event-descriptors.json + + func testEventDescriptorsFixture() throws { + let vectors = try Self.loadFixture("event-descriptors.json") as! [[String: Any]] + XCTAssertGreaterThan(vectors.count, 0) + + for vector in vectors { + let name = vector["name"] as! String + let descriptorRaw = vector["descriptor"] ?? NSNull() + let expectedValid = vector["valid"] as! Bool + + let isValid: Bool + do { + _ = try parseEventDescriptor(JSONValue.from(foundation: descriptorRaw)) + isValid = true + } catch { + isValid = false + } + + XCTAssertEqual(isValid, expectedValid, name) + } + } + // MARK: - close-codes.json func testCloseCodesFixture() throws { diff --git a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift index 970b97aa..00cffc32 100644 --- a/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift +++ b/packages/native/ios/Tests/AppductCoreTests/TestSupport.swift @@ -142,10 +142,11 @@ final class FakeTransportSession: AppductTransportSession, @unchecked Sendable { resumeToken: String = "resume-token", alias: String = "alias-1", keepaliveIntervalS: Double = 30, - graceS: Double = 120 + graceS: Double = 120, + eventRegistry: Bool = false ) { stateSnapshot = "active" - let payload: [String: Any] = [ + var payload: [String: Any] = [ "type": "session_ack", "session_id": sessionId, "status": "ok", @@ -154,6 +155,7 @@ final class FakeTransportSession: AppductTransportSession, @unchecked Sendable { "keepalive_interval_s": keepaliveIntervalS, "grace_s": graceS, ] + if eventRegistry { payload["event_registry"] = true } let data = try! JSONSerialization.data(withJSONObject: payload) simulateIncoming(String(data: data, encoding: .utf8)!) } From 22c6208ab9e3a00e88c59303dfa7ff0c59711877 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 2 Oct 2026 09:35:37 +0200 Subject: [PATCH 2/3] feat(ios): add registerEvent to the Swift SDK (#126) 4 failing -> 0 failing (compile failure -> 154 passing) --- CHANGELOG.md | 1 + packages/native/ios/README.md | 17 +++ .../Sources/AppductCore/Real/AppductAPI.swift | 22 ++++ .../Real/AppductClient+Session.swift | 51 +++++++- .../AppductCore/Real/AppductClient.swift | 23 ++++ .../Real/AppductEventDescriptor.swift | 113 ++++++++++++++++++ .../Real/AppductToolRegistry.swift | 4 +- .../AppductCore/Stub/AppductAPIStub.swift | 9 ++ .../AppductCore/Stub/AppductClientStub.swift | 20 ++++ 9 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6549f9..d2e3325c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ section into a versioned heading. ## Unreleased - **New: `appduct events ls` lists the events an app declares, with their descriptions and payload shapes.** `--name ` narrows the list, and an exact name prints the full payload schema. +- **New: the iOS SDK declares events with `Appduct.shared.registerEvent(name:description:payloadSchema:)`.** `appduct events ls` then lists them; against an older CLI the app keeps its session and tools but has no event list. - **New: a call to a backgrounded app fails at once with `session_suspended`, and the message says the app is in the background.** Needs an app built with this release; on Android, calls to a backgrounded app now fail instead of running until Android freezes the app. - **Fix: a session backgrounded or disconnected in a build with `trust: link` (the zero-config default) resumes automatically once the app comes back, instead of staying "Reconnecting" diff --git a/packages/native/ios/README.md b/packages/native/ios/README.md index 3bd72d9d..9158ee35 100644 --- a/packages/native/ios/README.md +++ b/packages/native/ios/README.md @@ -215,6 +215,23 @@ without a listener — useful for a view's initial render before its first event try await Appduct.shared.postEvent("checkout_completed", payload: ["orderId": "abc123"]) ``` +Declare the events your app posts so an agent can list them with `appduct events ls` before waiting +on one. `payloadSchema` is an optional JSON Schema object; it is shown to the agent, not checked +against what you post. A name is any string up to 4096 characters, dotted names included. + +```swift +let registration = try Appduct.shared.registerEvent( + name: "checkout_completed", + description: "Fired once an order finishes checkout.", + payloadSchema: ["type": "object", "properties": ["orderId": ["type": "string"]], "required": ["orderId"]] +) +// later, to withdraw it: +registration.remove() +``` + +Against an older `appduct` CLI that predates event lists, the app keeps its session and tools and +`appduct events ls` shows nothing. + Read back with `appduct events tail`. Throws (does not send) unless a session is currently active. ## Hardened builds diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift b/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift index f0518c59..b9f0da03 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift @@ -104,6 +104,28 @@ public final class Appduct: Sendable { ) } + // MARK: Event declaration + + /// Declares an event the app posts, so an agent can list it (`appduct events ls`) before waiting + /// on it. `name` is any string up to 4096 characters, dotted names included. `payloadSchema` is a + /// plain JSON Schema object; it is only listed, never checked against what `postEvent` sends. The + /// returned `EventRegistration.remove()` withdraws the declaration. + @discardableResult + public func registerEvent( + name: String, + description: String, + payloadSchema: [String: Any]? = nil + ) throws -> EventRegistration { + var schemaObject: JSONObject? + if let payloadSchema { + guard let converted = try? Appduct.jsonValue(fromFoundation: payloadSchema), let object = converted.objectValue else { + throw ToolDescriptorValidationError("Event \"\(name)\" payloadSchema is not a valid JSON object.") + } + schemaObject = object + } + return try client.registerEvent(EventDescriptor(name: name, description: description, payloadSchema: schemaObject)) + } + // MARK: Deep links /// Feeds a deep link to the core. Returns `true` iff `url` carried an Appduct bootstrap payload diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift b/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift index 306915dd..5fe6c3ad 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift @@ -125,6 +125,7 @@ extension AppductClient { alias: lease.alias, keepaliveIntervalS: lease.keepaliveIntervalS, graceS: lease.graceS, + eventRegistry: false, // learned from the ack that resumes this lease disconnectedAtMs: lease.disconnectedAtMs.map(Double.init) ?? now, endpoint: (lease.endpoint.ip, lease.endpoint.port), linkPin: lease.linkPin @@ -235,6 +236,7 @@ extension AppductClient { alias: ack.alias, keepaliveIntervalS: ack.keepaliveIntervalS, graceS: ack.graceS, + eventRegistry: ack.eventRegistry, disconnectedAtMs: nil, endpoint: endpoint, linkPin: linkPin @@ -244,7 +246,10 @@ extension AppductClient { setClientState(.active) emitSessionChange(type: kind, sessionId: ack.sessionId, alias: ack.alias) - Task { await self.sendSnapshot() } + Task { + await self.sendSnapshot() + await self.sendEventSnapshot() + } } func finalizeSessionLost(_ reason: String) { @@ -400,7 +405,8 @@ extension AppductClient { resumeToken: resumeToken, alias: alias, keepaliveIntervalS: keepaliveIntervalS, - graceS: graceS + graceS: graceS, + eventRegistry: object["event_registry"]?.boolValue == true ) } @@ -526,7 +532,46 @@ extension AppductClient { try? await sendWire(message) } - func sendToolRegistryDelta(_ delta: AppductRegistryDelta) async { + /// Event frames go out only on a session whose ack said the daemon accepts them: an older + /// daemon closes the session on an unknown frame type. + private var eventFramesAllowed: String? { + guard clientState == .active, let held = heldSession, held.eventRegistry else { return nil } + return held.sessionId + } + + func sendEventSnapshot() async { + guard let sessionId = eventFramesAllowed else { return } + let message = JSONValue.object([ + "type": .string("event_registry_snapshot"), + "session_id": .string(sessionId), + "events": .array(eventStore.snapshot().map { $0.wireValue }), + ]) + try? await sendWire(message) + } + + func sendEventRegistryDelta(_ delta: AppductRegistryDelta) async { + guard let sessionId = eventFramesAllowed else { return } + + var object: JSONObject = ["type": .string("event_registry_delta"), "session_id": .string(sessionId)] + switch delta { + case .upsert(let descriptor): + object["operation"] = .string("upsert") + object["event"] = descriptor.wireValue + case .remove(let name): + object["operation"] = .string("remove") + object["name"] = .string(name) + } + + do { + try await sendWire(.object(object)) + } catch { + emitError( + AppductUnifiedErrorEvent(phase: "socket", message: "Failed to sync the event registry.") + ) + } + } + + func sendToolRegistryDelta(_ delta: AppductRegistryDelta) async { guard clientState == .active, let sessionId = heldSession?.sessionId else { return } var object: JSONObject = ["type": .string("tool_registry_delta"), "session_id": .string(sessionId)] diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift b/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift index 9413bff6..ab31e026 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift @@ -32,6 +32,7 @@ public actor AppductClient { var alias: String var keepaliveIntervalS: Double var graceS: Double + var eventRegistry: Bool var disconnectedAtMs: Double? var endpoint: (ip: String, port: Int) /// The SPKI pin the claim that started this session trusted (`trust: link`, no embedded @@ -55,6 +56,8 @@ public actor AppductClient { let alias: String let keepaliveIntervalS: Double let graceS: Double + /// The ack carried `event_registry: true`: the daemon accepts `event_registry_*` frames. + let eventRegistry: Bool } var epoch: Int = 0 @@ -73,6 +76,10 @@ public actor AppductClient { /// Not actor-isolated -- see `AppductToolRegistryStore`'s doc comment. let registryStore = AppductToolRegistryStore() + // MARK: Event registry (declaration order preserved) + + let eventStore = AppductEventRegistryStore() + // MARK: In-flight tool calls final class InFlightToolCall { @@ -233,6 +240,22 @@ public actor AppductClient { Task { await self.sendToolRegistryDelta(.remove(name)) } } + /// Declares (or replaces, by name) an event the app posts. Validates like `@appduct/shared`'s + /// `isEventDescriptor` (PROTOCOL.md §5a) and throws on an invalid one. Sends an + /// `event_registry_delta` while a session is active and its ack carried `event_registry: true`; + /// otherwise the declaration waits for the next such ack's snapshot. `remove()` on the returned + /// registration withdraws it. + public nonisolated func registerEvent(_ descriptor: EventDescriptor) throws -> EventRegistration { + try validateEventDescriptor(descriptor) + eventStore.upsert(descriptor) + Task { await self.sendEventRegistryDelta(.upsert(descriptor)) } + let name = descriptor.name + return EventRegistration { [weak self] in + guard let self, self.eventStore.remove(name) else { return } + Task { await self.sendEventRegistryDelta(.remove(name)) } + } + } + // MARK: postEvent public struct AppductNotActiveError: Error, Sendable {} diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift b/packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift new file mode 100644 index 00000000..c902c6f1 --- /dev/null +++ b/packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift @@ -0,0 +1,113 @@ +// Vendored into @appduct/react-native at build time by scripts/sync-native-core.mjs -- see +// packages/native/README.md. +#if APPDUCT_ENABLED + +import Foundation + +/// Mirrors `@appduct/shared`'s `EventDescriptor` (PROTOCOL.md §5a): an event the app posts, +/// declared so an agent can list it before waiting on it. +public struct EventDescriptor: Sendable, Equatable { + public var name: String + public var description: String + public var payloadSchema: JSONObject? + + public init(name: String, description: String, payloadSchema: JSONObject? = nil) { + self.name = name + self.description = description + self.payloadSchema = payloadSchema + } + + public var wireValue: JSONValue { + var out: JSONObject = ["name": .string(name), "description": .string(description)] + if let payloadSchema { out["payload_schema"] = .object(payloadSchema) } + return .object(out) + } +} + +/// A handle returned by `AppductClient.registerEvent`. `remove()` withdraws the declaration; +/// letting this value go out of scope does **not**, matching `ToolRegistration`. +public struct EventRegistration: Sendable { + private let unregister: @Sendable () -> Void + + init(_ unregister: @escaping @Sendable () -> Void) { + self.unregister = unregister + } + + public func remove() { + unregister() + } +} + +private let maxEventNameLength = 4_096 +private let maxEventDescriptionLength = 4_096 + +/// Ports `@appduct/shared`'s `isEventDescriptor` (PROTOCOL.md §5a): a name is any non-empty string +/// of at most 4096 UTF-16 code units (the posted-name rule, not the tool-name pattern) and a +/// description is 1 to 4096 UTF-16 code units. `payloadSchema`'s type already guarantees a JSON +/// object. +public func validateEventDescriptor(_ descriptor: EventDescriptor) throws { + guard !descriptor.name.isEmpty, descriptor.name.utf16.count <= maxEventNameLength else { + throw ToolDescriptorValidationError("Event name must be 1 to \(maxEventNameLength) characters.") + } + guard !descriptor.description.isEmpty, descriptor.description.utf16.count <= maxEventDescriptionLength else { + throw ToolDescriptorValidationError( + "Event \"\(descriptor.name)\" description must be 1 to \(maxEventDescriptionLength) characters." + ) + } +} + +/// Parses a raw wire `EventDescriptor` JSON object into the typed struct and validates it. +/// `payload_schema` must be a JSON object if present; an explicit `null` is rejected, as in TS. +public func parseEventDescriptor(_ value: JSONValue) throws -> EventDescriptor { + guard case .object(let object) = value else { + throw ToolDescriptorValidationError("Event descriptor must be a JSON object.") + } + guard let name = object["name"]?.stringValue, let description = object["description"]?.stringValue else { + throw ToolDescriptorValidationError("Event descriptor needs a string \"name\" and \"description\".") + } + + var payloadSchema: JSONObject? + if let raw = object["payload_schema"] { + guard let schema = raw.objectValue else { + throw ToolDescriptorValidationError("Event \"\(name)\" payload_schema must be a JSON object.") + } + payloadSchema = schema + } + + let descriptor = EventDescriptor(name: name, description: description, payloadSchema: payloadSchema) + try validateEventDescriptor(descriptor) + return descriptor +} + +/// Declared events in declaration order (an update to an existing name keeps its position). +/// Lock-guarded rather than actor-isolated for the same reason as `AppductToolRegistryStore`: +/// `registerEvent` is synchronous and throwing. +final class AppductEventRegistryStore: @unchecked Sendable { + private let lock = NSLock() + private var order: [String] = [] + private var entries: [String: EventDescriptor] = [:] + + func upsert(_ descriptor: EventDescriptor) { + lock.lock() + defer { lock.unlock() } + if entries[descriptor.name] == nil { order.append(descriptor.name) } + entries[descriptor.name] = descriptor + } + + @discardableResult + func remove(_ name: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard entries.removeValue(forKey: name) != nil else { return false } + order.removeAll { $0 == name } + return true + } + + func snapshot() -> [EventDescriptor] { + lock.lock() + defer { lock.unlock() } + return order.compactMap { entries[$0] } + } +} + +#endif diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductToolRegistry.swift b/packages/native/ios/Sources/AppductCore/Real/AppductToolRegistry.swift index 6c3d0072..e7094a8e 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductToolRegistry.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductToolRegistry.swift @@ -4,8 +4,8 @@ import Foundation -public enum AppductRegistryDelta: Sendable, Equatable { - case upsert(ToolDescriptor) +public enum AppductRegistryDelta: Sendable, Equatable { + case upsert(Descriptor) case remove(String) } diff --git a/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift b/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift index 40c3d9b2..fd670cce 100644 --- a/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift +++ b/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift @@ -48,6 +48,15 @@ public final class Appduct: Sendable { ToolRegistration {} } + @discardableResult + public func registerEvent( + name: String, + description: String, + payloadSchema: [String: Any]? = nil + ) throws -> EventRegistration { + EventRegistration {} + } + public func handle(_ url: URL) -> Bool { false } public func postEvent(_ name: String, payload: Any? = nil) async throws {} diff --git a/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift b/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift index d6bcfc67..ced19d47 100644 --- a/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift +++ b/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift @@ -62,6 +62,25 @@ public struct ToolDescriptor: Sendable, Equatable { } } +// MARK: Event descriptor (mirrors Real/AppductEventDescriptor.swift's public surface) + +public struct EventDescriptor: Sendable, Equatable { + public var name: String + public var description: String + public var payloadSchema: JSONObject? + + public init(name: String, description: String, payloadSchema: JSONObject? = nil) { + self.name = name + self.description = description + self.payloadSchema = payloadSchema + } +} + +public struct EventRegistration: Sendable { + init(_ unregister: @escaping @Sendable () -> Void) {} + public func remove() {} +} + // MARK: Tool invocation public struct ToolCallContext: Sendable { @@ -166,6 +185,7 @@ public actor AppductClient { public nonisolated func registerTool(_ descriptor: ToolDescriptor, handler: @escaping ToolHandler) throws {} public nonisolated func unregisterTool(_ name: String) {} + public nonisolated func registerEvent(_ descriptor: EventDescriptor) throws -> EventRegistration { EventRegistration {} } public nonisolated var registeredTools: [ToolDescriptor] { [] } public nonisolated func handleUrl(_ url: String) -> Bool { false } From 65338d23a4c1aacb05b94fa7f956aecc154c538f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 2 Oct 2026 09:56:31 +0200 Subject: [PATCH 3/3] fix(ios): send event registry frames through one ordered queue (#126) Snapshot at ack time first, then deltas in call order; 9/25 fixture runs failing -> 0/40 --- packages/native/fixtures/README.md | 9 +++- packages/native/ios/README.md | 4 +- .../Real/AppductClient+Session.swift | 44 +++++++++---------- .../AppductCore/Real/AppductClient.swift | 16 +++++-- .../Real/AppductEventDescriptor.swift | 35 +++++++++++++-- .../AppductEventRegistryTests.swift | 19 ++++++++ 6 files changed, 95 insertions(+), 32 deletions(-) diff --git a/packages/native/fixtures/README.md b/packages/native/fixtures/README.md index bfa77c47..1cd60886 100644 --- a/packages/native/fixtures/README.md +++ b/packages/native/fixtures/README.md @@ -82,7 +82,14 @@ descriptor in `declaredBeforeAck` (valid `EventDescriptor`s in wire form), conne (`{ "op": "register", "event": }` or `{ "op": "remove", "name": }`, where `remove` is the disposer of the event registered under that name). `frames` is the complete, ordered list of `event_registry_*` frames the SDK must have sent, compared as JSON (key order does not -matter); other frames, such as `tool_registry_snapshot`, are ignored. Covers the snapshot of +matter); other frames, such as `tool_registry_snapshot`, are ignored. + +Ordering contract: after a `session_ack` carrying `event_registry: true`, the SDK sends the +snapshot of the declarations as they stood at ack time before any later delta, and deltas go out in +the order the calls were made (a `remove` followed by a `register` of the same name sends the +remove first). Deltas for declarations made before the ack are covered by the snapshot and are not +sent. Every SDK, the Kotlin one included, must meet this; the fixture steps run back to back with no +wait between them, so an SDK that sends from unordered tasks fails it intermittently. Covers the snapshot of several events, the empty snapshot, an upsert delta and a remove delta. ### `tool-descriptors.json` diff --git a/packages/native/ios/README.md b/packages/native/ios/README.md index 9158ee35..5e3005af 100644 --- a/packages/native/ios/README.md +++ b/packages/native/ios/README.md @@ -215,6 +215,8 @@ without a listener — useful for a view's initial render before its first event try await Appduct.shared.postEvent("checkout_completed", payload: ["orderId": "abc123"]) ``` +Read back with `appduct events tail`. Throws (does not send) unless a session is currently active. + Declare the events your app posts so an agent can list them with `appduct events ls` before waiting on one. `payloadSchema` is an optional JSON Schema object; it is shown to the agent, not checked against what you post. A name is any string up to 4096 characters, dotted names included. @@ -232,8 +234,6 @@ registration.remove() Against an older `appduct` CLI that predates event lists, the app keeps its session and tools and `appduct events ls` shows nothing. -Read back with `appduct events tail`. Throws (does not send) unless a session is currently active. - ## Hardened builds By default a build trusts whatever pin the deep link itself carries for that session diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift b/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift index 5fe6c3ad..e101229d 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductClient+Session.swift @@ -246,10 +246,9 @@ extension AppductClient { setClientState(.active) emitSessionChange(type: kind, sessionId: ack.sessionId, alias: ack.alias) - Task { - await self.sendSnapshot() - await self.sendEventSnapshot() - } + eventSnapshotSentFor = nil + eventStore.queueSnapshot() + Task { await self.sendSnapshot() } } func finalizeSessionLost(_ reason: String) { @@ -539,31 +538,30 @@ extension AppductClient { return held.sessionId } - func sendEventSnapshot() async { - guard let sessionId = eventFramesAllowed else { return } - let message = JSONValue.object([ - "type": .string("event_registry_snapshot"), - "session_id": .string(sessionId), - "events": .array(eventStore.snapshot().map { $0.wireValue }), - ]) - try? await sendWire(message) - } - - func sendEventRegistryDelta(_ delta: AppductRegistryDelta) async { + func sendEventRegistryOp(_ op: AppductEventRegistryOp) async { guard let sessionId = eventFramesAllowed else { return } - var object: JSONObject = ["type": .string("event_registry_delta"), "session_id": .string(sessionId)] - switch delta { - case .upsert(let descriptor): - object["operation"] = .string("upsert") - object["event"] = descriptor.wireValue - case .remove(let name): - object["operation"] = .string("remove") - object["name"] = .string(name) + var object: JSONObject = ["session_id": .string(sessionId)] + switch op { + case .snapshot(let events): + object["type"] = .string("event_registry_snapshot") + object["events"] = .array(events.map { $0.wireValue }) + case .delta(let delta): + guard eventSnapshotSentFor == sessionId else { return } + object["type"] = .string("event_registry_delta") + switch delta { + case .upsert(let descriptor): + object["operation"] = .string("upsert") + object["event"] = descriptor.wireValue + case .remove(let name): + object["operation"] = .string("remove") + object["name"] = .string(name) + } } do { try await sendWire(.object(object)) + if case .snapshot = op { eventSnapshotSentFor = sessionId } } catch { emitError( AppductUnifiedErrorEvent(phase: "socket", message: "Failed to sync the event registry.") diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift b/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift index ab31e026..a4c617da 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductClient.swift @@ -79,6 +79,9 @@ public actor AppductClient { // MARK: Event registry (declaration order preserved) let eventStore = AppductEventRegistryStore() + /// The session whose event snapshot has gone out; deltas queued before it are dropped, since the + /// snapshot already holds them. + var eventSnapshotSentFor: String? // MARK: In-flight tool calls @@ -130,6 +133,15 @@ public actor AppductClient { // caller already holds a reference returned by this initializer. let instance = self Task { await instance.wireTransportAndForeground() } + + // One consumer sends event-registry frames in the order the store queued them. + let ops = eventStore.ops + Task { [weak self] in + for await op in ops { + guard let self else { return } + await self.sendEventRegistryOp(op) + } + } } private func wireTransportAndForeground() { @@ -248,11 +260,9 @@ public actor AppductClient { public nonisolated func registerEvent(_ descriptor: EventDescriptor) throws -> EventRegistration { try validateEventDescriptor(descriptor) eventStore.upsert(descriptor) - Task { await self.sendEventRegistryDelta(.upsert(descriptor)) } let name = descriptor.name return EventRegistration { [weak self] in - guard let self, self.eventStore.remove(name) else { return } - Task { await self.sendEventRegistryDelta(.remove(name)) } + _ = self?.eventStore.remove(name) } } diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift b/packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift index c902c6f1..61ca6cec 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductEventDescriptor.swift @@ -79,19 +79,40 @@ public func parseEventDescriptor(_ value: JSONValue) throws -> EventDescriptor { return descriptor } -/// Declared events in declaration order (an update to an existing name keeps its position). -/// Lock-guarded rather than actor-isolated for the same reason as `AppductToolRegistryStore`: -/// `registerEvent` is synchronous and throwing. +/// One unit of event-registry wire work, in the order the calls were made. +enum AppductEventRegistryOp: Sendable { + /// The declarations as they stood when a session ack arrived. + case snapshot([EventDescriptor]) + case delta(AppductRegistryDelta) +} + +/// Declared events in declaration order (an update to an existing name keeps its position), plus +/// the single ordered queue of wire work they produce. Every mutation and the op it implies are +/// queued under one lock, so the queue order is the call order: a snapshot captured at ack time +/// precedes every delta from a later `registerEvent` or `remove()`, and a `remove()` followed by a +/// `registerEvent` of the same name can never be reordered. Lock-guarded rather than +/// actor-isolated for the same reason as `AppductToolRegistryStore`: `registerEvent` is +/// synchronous and throwing. final class AppductEventRegistryStore: @unchecked Sendable { private let lock = NSLock() private var order: [String] = [] private var entries: [String: EventDescriptor] = [:] + private let continuation: AsyncStream.Continuation + /// Consumed by exactly one task, which sends each op in turn. + let ops: AsyncStream + + init() { + (ops, continuation) = AsyncStream.makeStream(of: AppductEventRegistryOp.self) + } + + deinit { continuation.finish() } func upsert(_ descriptor: EventDescriptor) { lock.lock() defer { lock.unlock() } if entries[descriptor.name] == nil { order.append(descriptor.name) } entries[descriptor.name] = descriptor + continuation.yield(.delta(.upsert(descriptor))) } @discardableResult @@ -100,6 +121,7 @@ final class AppductEventRegistryStore: @unchecked Sendable { defer { lock.unlock() } guard entries.removeValue(forKey: name) != nil else { return false } order.removeAll { $0 == name } + continuation.yield(.delta(.remove(name))) return true } @@ -108,6 +130,13 @@ final class AppductEventRegistryStore: @unchecked Sendable { defer { lock.unlock() } return order.compactMap { entries[$0] } } + + /// Queues a snapshot of the declarations as they stand now. + func queueSnapshot() { + lock.lock() + defer { lock.unlock() } + continuation.yield(.snapshot(order.compactMap { entries[$0] })) + } } #endif diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift index dcd93c01..30bb1afc 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductEventRegistryTests.swift @@ -145,6 +145,25 @@ final class AppductEventRegistryTests: XCTestCase { XCTAssertEqual(eventFrames(transport).count, 2) } + func testRemoveThenRegisterOfTheSameNameInALoopEndsWithTheUpsert() async throws { + let (client, transport) = makeClient() + var registration = try client.registerEvent(descriptor("a")) + try await connect(client, transport, eventRegistry: true) + + for _ in 0..<50 { + registration.remove() + registration = try client.registerEvent(descriptor("a")) + } + try await waitUntil("every frame reached the wire") { self.eventFrames(transport).count >= 101 } + await allowQueuedWorkToRun() + + let frames = eventFrames(transport) + XCTAssertEqual(frames.count, 101) + XCTAssertEqual(frames.last?["operation"]?.stringValue, "upsert") + let operations = frames.dropFirst().compactMap { $0["operation"]?.stringValue } + XCTAssertEqual(operations, Array(repeating: ["remove", "upsert"], count: 50).flatMap { $0 }) + } + func testRegisterEventRejectsAnInvalidDescriptorAndDeclaresNothing() async throws { let (client, transport) = makeClient() XCTAssertThrowsError(try client.registerEvent(EventDescriptor(name: "", description: "d")))