Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ section into a versioned heading.

- **New: `appduct_list_events` MCP tool lists the events an app declares, with descriptions.** `name` takes a glob like `"cart.*"`, and an exact name also returns the payload schema.
- **New: `appduct events ls` lists the events an app declares, with their descriptions and payload shapes.** `--name <glob>` narrows the list, `--limit <n>`/`--offset <n>` page through it, 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"
Expand Down
24 changes: 22 additions & 2 deletions packages/native/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,28 @@ 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of the blocker on the test: "deliver a session_ack ..., then apply each afterAck step" gives no sync point. Yet frames requires the snapshot to show exactly declaredBeforeAck and to come before every delta. A Kotlin harness written to this text will hit the same race. Name the sync point here (for example "once the event_registry_snapshot frame has been sent"), or state that the SDK must order them.

(`{ "op": "register", "event": <descriptor> }` or `{ "op": "remove", "name": <string> }`, 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.

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`

Expand Down
154 changes: 154 additions & 0 deletions packages/native/fixtures/event-registry-frames.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
17 changes: 17 additions & 0 deletions packages/native/ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,23 @@ try await Appduct.shared.postEvent("checkout_completed", payload: ["orderId": "a

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.

```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.

## Hardened builds

By default a build trusts whatever pin the deep link itself carries for that session
Expand Down
22 changes: 22 additions & 0 deletions packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -235,6 +236,7 @@ extension AppductClient {
alias: ack.alias,
keepaliveIntervalS: ack.keepaliveIntervalS,
graceS: ack.graceS,
eventRegistry: ack.eventRegistry,
disconnectedAtMs: nil,
endpoint: endpoint,
linkPin: linkPin
Expand All @@ -244,6 +246,8 @@ extension AppductClient {
setClientState(.active)
emitSessionChange(type: kind, sessionId: ack.sessionId, alias: ack.alias)

eventSnapshotSentFor = nil
eventStore.queueSnapshot()
Task { await self.sendSnapshot() }
}

Expand Down Expand Up @@ -400,7 +404,8 @@ extension AppductClient {
resumeToken: resumeToken,
alias: alias,
keepaliveIntervalS: keepaliveIntervalS,
graceS: graceS
graceS: graceS,
eventRegistry: object["event_registry"]?.boolValue == true
)
}

Expand Down Expand Up @@ -526,7 +531,45 @@ 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 sendEventRegistryOp(_ op: AppductEventRegistryOp) async {
guard let sessionId = eventFramesAllowed else { return }

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.")
)
}
}

func sendToolRegistryDelta(_ delta: AppductRegistryDelta<ToolDescriptor>) async {
guard clientState == .active, let sessionId = heldSession?.sessionId else { return }

var object: JSONObject = ["type": .string("tool_registry_delta"), "session_id": .string(sessionId)]
Expand Down
33 changes: 33 additions & 0 deletions packages/native/ios/Sources/AppductCore/Real/AppductClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -73,6 +76,13 @@ public actor AppductClient {
/// Not actor-isolated -- see `AppductToolRegistryStore`'s doc comment.
let registryStore = AppductToolRegistryStore()

// 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

final class InFlightToolCall {
Expand Down Expand Up @@ -123,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() {
Expand Down Expand Up @@ -233,6 +252,20 @@ 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)
let name = descriptor.name
return EventRegistration { [weak self] in
_ = self?.eventStore.remove(name)
}
}

// MARK: postEvent

public struct AppductNotActiveError: Error, Sendable {}
Expand Down
Loading
Loading