Repository navigation
Description
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]
$ 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,isRotatableModelandmodelFamily; added toNOT_SHAPES),device.rotate, two error codes, protocol bump, matrices.src/core/—INPUT_ORIENTATIONSreads the contract's list;DeviceInput.sendreturnsPromise<void>; the fake device input.src/drivers/ios/input/,src/drivers/android/input/—sendreturns 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, thedevice.rotatehandler, closing at lease end,ctx.inputshanding modules the lease's shared input.src/modules/—ModuleInputsgainsleaseInput(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 fromctx.inputs.leaseInput;worker.test.ts's fake follows.src/gateway/—device.rotatehandler, 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/—rotateand 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*]
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
Contract and event changes
This PR bumps the protocol by one.
device.rotate: input{leaseId: string, orientation: one of F1, requesterId?: string}(leaseId,requesterIdbounded asdevice.exec's); output{leaseId, orientation}; roleagent; effectwrite; authorize asdevice.exec(F13). Any other orientation string, an empty one, or a missing one isBAD_REQUEST.ROTATION_UNSUPPORTED: 422, exit 12, details{leaseId, platform: "ios"|"android", physical: boolean, class: string};classis the physical class (phone,tablet), the simulator'smodelFamily(tv,watch,vision,unknown), oremulator. A simulator is rotatable whenisRotatableModel(spec.model): the model starts withiPhoneoriPad(F16, F26); any other model, an unknown one included, is refused without a catalog read.INPUT_UNAVAILABLE: 503, exit 1, details{leaseId, reason};reason1 to 200 characters, the factory'sunavailabletext or the open's or write's error message, cut;unknown errorwhen that text is empty.src/contract/orientation.tsis a helper with no wire shape of its own, listed inNOT_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'ssendcatches and drops the promise's rejection, so it is never unhandled (F27); the failed write still reaches the sockets throughonFailed, which closes them 1013, as today. onFailedon the shared input: the registry drops it and every socket closes 1013, as today; a rotate in flight getsINPUT_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_LEASEand 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: "
0x82keeps sayingportrait" — 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.rotateon its iOS-simulator lease and gets{leaseId, orientation}; the helper received0x07with 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 arequesterId→FORBIDDEN; admin naming arequesterIdthat 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_UNSUPPORTEDnaming 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_UNAVAILABLEwith 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}/rotateon 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); gatewayROTATION_UNSUPPORTEDandINPUT_UNAVAILABLEname 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_UNSUPPORTEDthrown 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.tsandsrc/daemon/input-lease.e2e.test.tspass with only the fake's shape changed. operations.test.ts's role and effect matrices holddevice.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
- The CLI command and MCP tool (simlock rotate and MCP rotate_device #1108); the agent-device front (The agent-device front answers orientation on an iOS-simulator lease #1109).
- Android rotation; reporting the orientation in
0x82.
Depends on
Written by an agent.
Activity
test: a caller rotates a lease it may not touch, or names a bad orien…
feat(contract): device.rotate is authorized like device.exec (callsta…
test: a holder rotates a lease not on an iOS phone or tablet simulato…
feat(daemon): device.rotate refuses a lease not on an iOS phone or ta…
test: a holder rotates when the helper cannot start or the write fail…
feat(daemon): device.rotate answers INPUT_UNAVAILABLE with the reason…
test: an input socket opened after a rotate's helper closed gets a fr…
feat(daemon): one device input per lease, shared by the input module'…
test: a client program calls client.rotate(leaseId, orientation) and …
feat(client): client.rotate turns a lease's simulator through device.…
test: an agent calls POST /v1/leases/{id}/rotate on a worker and thro…
feat(http): POST /v1/leases/{id}/rotate on a worker and through a gat…
docs: rotate over HTTP and its two error codes (callstackincubator#1107)
test: the alias parser maps a spelling to an orientation or the unkno…
feat(contract): parseOrientation maps an alias to an orientation or t…
test: the gateway coordinator forwards device.rotate and names the ca…
test: a socket whose write fails closes 1013 while the daemon keeps r…
test: fold the coordinator's rotate case into the HTTP gateway test t…
chore: name how the coordinator's rotate is reached for Fallow (calls…
fix(gateway): name the caller's lease id in every worker error, and p…
docs: list POST /v1/leases/{id}/rotate and device.rotate in the manua…
fix(modules): reserve the rotate lease sub-route for the core route (c…
test(contract): prove device.rotate's input bounds and strict keys (c…
test(gateway): prove gateway.execTimeoutMs bounds a forwarded device.…
test(daemon): drop the failed-open case device-rotate's e2e test alre…
chore(gateway): drop the Fallow suppression the rotate timeout test m…
fix(contract): bump the protocol to 68, since main took 67 (callstack…
feat(daemon): device.rotate rotates a leased iOS simulator (callstack…