Skip to content

device.rotate: rotate a leased iOS simulator over the contract, HTTP and the client #1107

Closed

Description

@leonardoxr

Part of #1105.

Scope

A holder rotates its iOS phone or tablet simulator lease with one call over the daemon contract, HTTP (POST /v1/leases/{id}/rotate) or the JS client, on a worker or through a gateway, whether or not capabilities.input.enabled is on. The per-lease device input moves into the daemon (ADR 0053), shared by the rotate and the input module's sockets, and DeviceInput.send returns a promise so a failed write is reported. The orientation list and alias parser the CLI, MCP and the agent-device front use ship here, in the contract. The watching-the-screen page describes the action.

In short

After this PR, an agent sends POST /v1/leases/<lease-id>/rotate with {"orientation":"landscape_left"} and its leased iPhone or iPad simulator turns, answering 200 {"leaseId":"<lease-id>","orientation":"landscape_left"} once the event is written to the input helper. Through a gateway, <lease-id> is always the id the agent holds. A lease on anything else answers 422 ROTATION_UNSUPPORTED with Lease <lease-id> is on <device>; only an iOS simulator can be rotated, <device> one of an Apple TV simulator, an Apple Watch simulator, an Apple Vision simulator, a <model> simulator (another iOS model), an Android emulator, an Android phone, an Android tablet, an iPhone, an iPad. A helper that cannot start, or a write that fails, answers 503 INPUT_UNAVAILABLE with Lease <lease-id>'s simulator cannot take input: <reason>, <reason> the driver's text cut to 200 characters. With an input socket open, its helper is reused.

flowchart LR
  C[HTTP / client / gateway] --> O[device.rotate - new]
  O --> R[daemon per-lease device input - moved here]
  S[input module socket] --> R
  R --> H[iOS input helper: 0x07, send awaited - changed]
Loading
$ curl -X POST -H "Authorization: Bearer $T" -d '{"orientation":"landscape_left"}' http://worker:7300/v1/leases/3f9c/rotate
404                                                    # before
{"leaseId":"3f9c","orientation":"landscape_left"}      # after
$ curl ... -d '{"orientation":"landscape-left"}' ...
400 {"code":"BAD_REQUEST", ...}                        # after: HTTP takes only the four names
orientation sent over HTTP or the client Today After
landscape_left no route rotates
landscape-left, LEFT, auto, empty, missing no route 400 BAD_REQUEST

If this goes wrong, a user sees an input socket lose its touches when a rotate runs at the same time, or a rotate that answers 200 but never turns the simulator.

Technical spec

Facts

Commit 795733b.

# Fact Source
F1 The four orientation names are portrait, portrait_upside_down, landscape_left, landscape_right, declared in core and not exported from the contract src/core/input.ts:35-40
F2 0x07 is tag 7 and the socket parser keeps one only with one of those names src/core/input.ts:9, src/core/input.ts:211-214
F3 The iOS device input forwards an orientation message as a 0x07 frame to its helper src/drivers/ios/input/device-input.ts:140-141
F4 DeviceInput.send returns void; a factory may answer {unavailable} src/core/input.ts:87-113
F5 iOS send writes with writeStdin(...).catch(...) and reports a failed write only through #fail (onFailed), never by throwing src/drivers/ios/input/device-input.ts:175-188
F6 Android send writes touch, scroll, button and key packets and drops other kinds src/drivers/android/input/device-input.ts:188-197
F7 The driver catalog's openInput answers {unavailable: "<platform> devices take no input"} with no factory src/core/driver-catalog.ts:95-101
F8 The iOS helper waits at most 10 s to be ready and a failed start rejects the open src/drivers/ios/input/device-input.ts:38, src/drivers/ios/input/device-input.ts:53-56
F9 The input module keeps one device input per lease, shared by its sockets, closed when the last socket leaves, dropped on onFailed src/input/sessions.ts:32-39, src/input/sessions.ts:137-140, src/input/sessions.ts:163
F10 The module's only opener is ctx.inputs.openInput(platform, device, signal), with no lease id, provided by the daemon from the catalog src/modules/types.ts:190-197, src/daemon/main.ts:519-520
F11 The module's test fakes inputs.openInput and asserts sharing inside the module (8 sockets, one open) src/input/worker.test.ts:35, src/input/worker.test.ts:143, src/input/worker.test.ts:217
F12 Lease end reaches the input module through beforeDeviceReclaim; lease-end participants run at once, not in order src/input/worker.ts:16, src/leasing/lease-release-coordinator.ts:382
F13 device.exec authorize: an agent owns the lease and names no requesterId; an admin is held to the lease's requester, requesterId defaulting to its principal; unknown ids pass to the handler src/contract/operations.ts:486-496
F14 The exec handler finds the lease and its device in the registry snapshot and throws UnknownLeaseError for either missing src/daemon/dispatcher.ts:667-674
F15 A device spec carries platform and physical; a physical spec carries class (phone or tablet); a simulator's class comes from the catalog's classOf(model) src/core/domain.ts:29-30, src/core/domain.ts:59-63, src/core/domain.ts:140
F16 iOS models map to classes by product family: iPhone phone, iPad tablet, Apple Watch watch, Apple TV tv, Apple Vision vision; every model the driver lists starts with its family's name src/drivers/ios/index.ts:246-252, src/drivers/ios/index.ts:115-128
F17 A gateway lease id is <workerId>.<workerLeaseId>; the gateway rethrows a worker's DispatchError unchanged src/gateway/lease-index.ts:14, src/gateway/fleet-coordinator.ts:1395-1400
F18 The gateway's exec handler calls coordinator.exec, which namespaces the requester and calls the worker with its lease id through #forwardToWorker, bounded by gateway.execTimeoutMs src/gateway/dispatcher.ts:733-742, src/gateway/fleet-coordinator.ts:489-499, src/gateway/fleet-coordinator.ts:543-550
F19 The HTTP exec route dispatches device.exec directly so its authorize answers 403/404 src/http/app.ts:473-488
F20 Lease sub-routes are declared in LEASE_ROUTES; none is named rotate src/http/lease-declarations.ts:338-350
F21 The client's exec calls device.exec through #callRaw and parses the output src/simlock-client/client.ts:618-624
F22 The protocol range is { min: 66, max: 66 }; contract helpers that carry no wire shape are listed in NOT_SHAPES src/contract/protocol.ts:13, .agents/scripts/protocol-bump-check.mjs:13-18
F23 device.exec emits no business event, deliberately docs/internal/EVENTS.md:214
F24 The watching-the-screen page says rotation is 0x07 on the input socket only website/src/content/docs/docs/using-devices/watching-the-screen.mdx:111-119
F25 The input wiring test drives sockets against a fake input factory src/daemon/input-lease.e2e.test.ts:381-420
F26 A simulator's device record holds only spec.model for its kind; the catalog's classOf exists only during acquisition src/core/domain.ts:24-31, src/core/domain.ts:134-140
F27 The input module writes void opened.close(); nothing in src/ handles unhandledRejection src/input/sessions.ts:60, src/input/sessions.ts:192

Modules touched

Searches: openInput, createInputSessions, send(message, device.exec, INPUT_ORIENTATIONS, LEASE_ROUTES, beforeDeviceReclaim across src/.

  • src/contract/ — orientation.ts (the four names, the alias table, parseOrientation, the unknown message, isRotatableModel and modelFamily; added to NOT_SHAPES), device.rotate, two error codes, protocol bump, matrices.
  • src/core/ — INPUT_ORIENTATIONS reads the contract's list; DeviceInput.send returns Promise<void>; the fake device input.
  • src/drivers/ios/input/, src/drivers/android/input/ — send returns the write's promise (Android: resolves once the packet is written, at once for a dropped kind).
  • src/daemon/ — the per-lease device-input registry, the device.rotate handler, closing at lease end, ctx.inputs handing modules the lease's shared input.
  • src/modules/ — ModuleInputs gains leaseInput(leaseId, platform, device) returning the lease's shared input handle (by-shape type and its type test).
  • src/input/ — sessions take the lease's input from ctx.inputs.leaseInput; worker.test.ts's fake follows.
  • src/gateway/ — device.rotate handler, coordinator forward, mapping the worker's lease id back to the caller's.
  • src/http/ — the route declaration and handler.
  • src/simlock-client/, src/client/ — rotate and its types.
  • website/ — errors reference, HTTP reference, watching-the-screen.
flowchart LR
  http[http*] --> daemon[daemon dispatcher*]
  gateway[gateway*] --> daemon
  client[simlock-client*] --> daemon
  daemon --> reg[daemon device inputs*]
  input[input module*] --> reg
  reg --> catalog[core driver catalog]
  catalog --> drivers[drivers input send*]
  daemon --> contract[contract*]
Loading
sequenceDiagram
  participant A as Agent
  participant G as Gateway
  participant W as Worker
  participant H as iOS helper
  A->>G: POST /v1/leases/w1.3f9c/rotate {orientation}
  G->>W: device.rotate {3f9c, orientation, requesterId: gw:..:agent}
  W->>H: 0x07 {orientation} (send awaited)
  W-->>G: {leaseId: 3f9c, orientation}
  G-->>A: 200 {leaseId: w1.3f9c, orientation}
  Note over W,A: open or write fails: 503 INPUT_UNAVAILABLE; not a phone/tablet simulator: 422 ROTATION_UNSUPPORTED; both name w1.3f9c; worker gone: 503 WORKER_UNREACHABLE
Loading

Contract and event changes

This PR bumps the protocol by one.

  • device.rotate: input {leaseId: string, orientation: one of F1, requesterId?: string} (leaseId, requesterId bounded as device.exec's); output {leaseId, orientation}; role agent; effect write; authorize as device.exec (F13). Any other orientation string, an empty one, or a missing one is BAD_REQUEST.
  • ROTATION_UNSUPPORTED: 422, exit 12, details {leaseId, platform: "ios"|"android", physical: boolean, class: string}; class is the physical class (phone, tablet), the simulator's modelFamily (tv, watch, vision, unknown), or emulator. A simulator is rotatable when isRotatableModel(spec.model): the model starts with iPhone or iPad (F16, F26); any other model, an unknown one included, is refused without a catalog read.
  • INPUT_UNAVAILABLE: 503, exit 1, details {leaseId, reason}; reason 1 to 200 characters, the factory's unavailable text or the open's or write's error message, cut; unknown error when that text is empty.
  • src/contract/orientation.ts is a helper with no wire shape of its own, listed in NOT_SHAPES.
  • No event (F23; EVENTS.md unchanged).

Other code on the same state

Searches: openInput( (3 hits outside tests), .send( on a device input (2: src/input/sessions.ts, the fake), sockets.size (1), beforeDeviceReclaim (5).

  • Input module sessions (src/input/sessions.ts): a user of the shared input. A rotate during an open socket uses the same input and does not close it; a socket that opens while a rotate is in flight joins it; the last of either to finish closes it. A socket's send catches and drops the promise's rejection, so it is never unhandled (F27); the failed write still reaches the sockets through onFailed, which closes them 1013, as today.
  • onFailed on the shared input: the registry drops it and every socket closes 1013, as today; a rotate in flight gets INPUT_UNAVAILABLE; the next user opens a fresh input.
  • Lease end: the registry closes the lease's input from its own lease-end participant, not ordered against module hooks (F12). A rotate whose open is pending when the lease ends answers UNKNOWN_LEASE and sends nothing.
  • Stream and agent-device modules: do not touch device input.

Failure modes

Call Outcome What happens Limit, counted from
registry open of the lease's input ok send 0x07, await the write, answer 10 s, from the open (F8)
registry open {unavailable} INPUT_UNAVAILABLE with its text —
registry open rejects INPUT_UNAVAILABLE with the message; next open tries again 10 s, from the open
send rejects INPUT_UNAVAILABLE; the input is dropped (onFailed) —
lease end during the open — UNKNOWN_LEASE; nothing sent; input closed —
gateway forward worker not connected WORKER_UNREACHABLE, as exec —
gateway forward no answer EXEC_TIMEOUT, as exec gateway.execTimeoutMs, from the forward (F18)
lookup no lease or device UNKNOWN_LEASE —

A daemon restart mid-rotate leaves no state: the registry is memory only and the helper is a child. Two rotates at once share one open and both send, in arrival order.

Rules in play

  • ADR 0053 §1-6 (PR docs(adr): 0053 the daemon owns each lease's device input, and device.rotate uses it #1106): "The daemon keeps one device input per lease" — the registry and op follow it.
  • ADR 0052 §4: "0x82 keeps saying portrait" — unchanged.
  • ADR 0049 §4 narrowed by 0053: "A lease has one device input" stays true; it is now the daemon's.
  • Contract "Adding an operation" steps 1-8 (src/contract/AGENTS.md); "One OS grammar ... one duration parser. Nobody else parses" — one orientation parser, in the contract.

Tests

Seam: daemon dispatch over the socket with a fake input factory, modelled on src/daemon/input-lease.e2e.test.ts; HTTP on src/http/core-routes.test.ts; the client on its existing exec test.

  • A holder calls device.rotate on its iOS-simulator lease and gets {leaseId, orientation}; the helper received 0x07 with that orientation; cases: each of the four names; input module on with a socket open (its helper is reused, no second open); input module off (helper opened, then closed).
  • A caller rotates a lease it may not touch, or names a bad orientation, and is refused; cases: unknown lease id → UNKNOWN_LEASE; another requester's lease → FORBIDDEN; an agent naming a requesterId → FORBIDDEN; admin naming a requesterId that is not the lease's → FORBIDDEN; landscape-left, auto, empty → BAD_REQUEST.
  • A holder rotates a lease not on an iOS phone or tablet simulator and gets ROTATION_UNSUPPORTED naming it; no helper starts; cases: Apple TV simulator; an iOS simulator model with no known family (a <model> simulator); Android emulator; Android phone; iPhone; an iPad simulator rotates.
  • A holder rotates when the helper cannot start or the write fails and gets INPUT_UNAVAILABLE with the reason; cases: no input factory for the platform; helper open rejects; write rejects; a helper that failed earlier is replaced by a fresh one; a socket's failed write closes it 1013 and the daemon keeps running.
  • An input socket opened after a rotate's helper closed gets a fresh helper; a socket open during a rotate keeps its helper after the rotate answers; cases: socket opens after; socket open during; lease reclaimed while the rotate's open is pending → UNKNOWN_LEASE, nothing sent.
  • An agent calls POST /v1/leases/{id}/rotate on a worker and through a gateway and gets 200 {leaseId, orientation}, or the error code's status; cases: worker ok; gateway ok (forwarded to the worker's lease id, answered with the caller's); gateway ROTATION_UNSUPPORTED and INPUT_UNAVAILABLE name the caller's lease id; unknown lease 404; Android 422; helper missing 503.
  • A client program calls client.rotate(leaseId, orientation) and gets the op's output; cases: ok; ROTATION_UNSUPPORTED thrown as a typed error.
  • The core alias parser maps a spelling to an orientation or the unknown message; cases: every row of the feature's Examples table.

The socket behaviours src/input/worker.test.ts and src/daemon/input-lease.e2e.test.ts prove today stay proven: their assertions keep their meaning, with worker.test.ts's fake inputs moved to leaseInput (F11).

Done when

  • The Tests lines pass; src/input/worker.test.ts and src/daemon/input-lease.e2e.test.ts pass with only the fake's shape changed.
  • operations.test.ts's role and effect matrices hold device.rotate.
  • The errors reference lists both codes; the HTTP reference lists the route; watching-the-screen says rotation is also POST /v1/leases/{id}/rotate, with the input module off.

Out of scope

Depends on

Written by an agent.

Activity

  1. added 20 commits that reference this issue on Oct 31, 2026
  2. added a commit that references this issue on Oct 31, 2026
    603f52f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions