From 11cad9ffd107a37fdbba4d0a3ed6454502bbb49f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 11:00:00 +0200 Subject: [PATCH 1/5] docs(adr): 0044 a device's screen streams to operators, not a lease's (#906) --- ...creen-streams-to-operators-not-a-leases.md | 75 +++++++++++++++++++ docs/internal/adr/README.md | 1 + 2 files changed, 76 insertions(+) create mode 100644 docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md diff --git a/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md b/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md new file mode 100644 index 00000000..ddbb5ee8 --- /dev/null +++ b/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md @@ -0,0 +1,75 @@ +# 0044. A device's screen streams to operators, not a lease's + +- **Status:** Proposed +- **Date:** 2026-10-23 +- **Issue:** [#906](https://github.com/callstackincubator/simlock/issues/906) +- **Depends on:** ADR 0030 (stream is an optional module), ADR 0031 (screen-source seam), ADR 0039 (joined workers serve no HTTP of their own). + +## Context + +Today the only stream route is `GET /v1/stream/leases/{id}` (`src/stream/definition.ts:27` at `192cb5fbb`). The worker finds the lease, checks that the caller owns it unless the caller is an operator (`src/stream/worker-link.ts:120-121`), and keys each capture by lease id (`src/stream/captures.ts:37`). The lease-end participant stops every capture of the ended lease (`src/stream/captures.ts:102`). The module manual says a capture never outlives its lease. No ADR decided that. + +The driver opens a screen from a device and a platform, with no lease (`src/core/driver-catalog.ts:96-106`). The lease is there only to find the device and to check the caller. + +The console is the only caller (`ui/src/state/screen.ts:129`). An operator therefore cannot watch an idle or warm-pool device, and has to find a device's lease before watching it. + +## Decision + +1. **The route names a device.** A worker serves `GET /v1/stream/devices/{deviceId}`. A gateway serves `GET /v1/stream/workers/{workerId}/devices/{deviceId}` and relays it to that worker. A device id is unique only within one worker, so the gateway's path names the worker. `GET /v1/stream/leases/{id}` is removed. Nothing published depends on it. +2. **Operators only.** An agent token gets 404 `UNKNOWN_DEVICE` for any device, so an agent cannot list a fleet's devices this way. On a gateway the gateway checks the role. The worker serves any relayed device stream: a relay arrives only on a gateway's uplink session, so it is trusted as far as that session is. The uplink message does not change. +3. **A running device streams.** A device in state `ready` or `leased` streams. Any other state (`provisioning`, `shutdown`, `reclaiming`, `quarantined`, `absent`, `deleted`) gets 409 `DEVICE_NOT_RUNNING`, with the device's state in the message. An unknown device gets 404 `UNKNOWN_DEVICE`. A driver that cannot capture keeps 415 `STREAM_NOT_SUPPORTED`. Every other refusal stays as it is. +4. **A stream outlives a lease grant, not a reclaim.** A device that gets leased while it is watched keeps streaming, a physical iPhone included. A stream ends when: + - the viewer closes it; + - the capture ends or goes silent past the existing bound, which is also how a warm simulator that shuts down ends its stream; + - the device is reclaimed after a lease ends. The lease-end participant stops every capture on the ended lease's device before the device is erased. +5. **One capture per viewer, as today.** Sharing one capture between viewers (#602) is left to that issue, keyed by device. +6. **No business events.** Watching changes no lease and no device, as before. The daemon log keeps recording each stream opened and refused. + +```mermaid +sequenceDiagram + participant C as Console (operator) + participant G as Gateway + participant W as Worker + participant D as Driver + C->>G: GET /v1/stream/workers/w1/devices/d1 + alt not an operator + G-->>C: 404 UNKNOWN_DEVICE + else operator + G->>W: relay GET /stream/devices/d1 + alt unknown device + W-->>C: 404 UNKNOWN_DEVICE + else state not ready or leased + W-->>C: 409 DEVICE_NOT_RUNNING + else running + W->>D: openScreen(platform, device) + D-->>W: frames + W-->>C: 200, frames until close, capture end, silence or reclaim + end + end +``` + +```mermaid +stateDiagram-v2 + [*] --> Streaming: device ready or leased + Streaming --> Streaming: device leased + Streaming --> [*]: viewer closes + Streaming --> [*]: capture ends or goes silent (warm shutdown) + Streaming --> [*]: lease ends, device reclaimed +``` + +## Consequences + +- `src/stream/AGENTS.md`'s "a capture never outlives its lease" becomes "a capture never outlives its device's reclaim". +- ADR 0040 names the lease route as an example of a module route. That example now names the device routes. The rule itself does not change. +- The console shows the screen in the device drawer. The lease drawer has no Screen tab. +- The website's "Watching the screen" page describes the device routes and an operator token only. + +## Alternatives + +- **Keep the lease route beside the device route.** Rejected: only the console calls it, and it keeps a second access model (lease owners) to secure and test. +- **The relay carries the caller's role.** Rejected: it changes the uplink message, and it only defends against a gateway that is already trusted with every lease. +- **Reuse 415 for a device that is not running.** Rejected: 415 says the device can never stream, and 409 says it cannot stream now. + +## Verification + +The stream module's tests drive both routes with faked drivers: role, state and lifetime cases. The slow-lane stream tests move to the device routes. The feature's tasks name them. diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index 8822c4c9..14807e21 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -82,3 +82,4 @@ the status is stale. | [0041](0041-the-daemon-can-run-under-the-users-service-manager.md) | The daemon can run under the user's service manager | Accepted — not yet implemented | | [0042](0042-simlock-updates-itself-through-a-frozen-call-and-a-detached-updater.md) | Simlock updates itself through a frozen call and a detached updater | Accepted — not yet implemented; narrows 0009's `takes-requests` stage | | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part | +| [0044](0044-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Proposed | From 36d897ff6cdd975608e7417f0fd3a28921039f25 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 11:11:31 +0200 Subject: [PATCH 2/5] docs(adr): 0044 one route shape on every daemon (#906) --- ...evices-screen-streams-to-operators-not-a-leases.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md b/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md index ddbb5ee8..382d7c7f 100644 --- a/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md +++ b/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md @@ -3,7 +3,7 @@ - **Status:** Proposed - **Date:** 2026-10-23 - **Issue:** [#906](https://github.com/callstackincubator/simlock/issues/906) -- **Depends on:** ADR 0030 (stream is an optional module), ADR 0031 (screen-source seam), ADR 0039 (joined workers serve no HTTP of their own). +- **Depends on:** ADR 0012 (a worker answers fleet routes as a fleet of one), ADR 0030 (stream is an optional module), ADR 0031 (screen-source seam), ADR 0039 (joined workers serve no HTTP of their own). ## Context @@ -15,9 +15,9 @@ The console is the only caller (`ui/src/state/screen.ts:129`). An operator there ## Decision -1. **The route names a device.** A worker serves `GET /v1/stream/devices/{deviceId}`. A gateway serves `GET /v1/stream/workers/{workerId}/devices/{deviceId}` and relays it to that worker. A device id is unique only within one worker, so the gateway's path names the worker. `GET /v1/stream/leases/{id}` is removed. Nothing published depends on it. +1. **The route names a worker and a device.** Every daemon serves `GET /v1/stream/workers/{workerId}/devices/{deviceId}`. A device id is unique only within one worker, so the path names the worker. A gateway relays the request to that worker over its uplink, to the worker's internal `/stream/devices/{deviceId}`. A worker answers the same public path as a fleet of one, as ADR 0012 has it: `{workerId}` is its own instance id, the id `worker.list` gives it. The console therefore builds one URL on either daemon. `GET /v1/stream/leases/{id}` is removed. Nothing published depends on it. 2. **Operators only.** An agent token gets 404 `UNKNOWN_DEVICE` for any device, so an agent cannot list a fleet's devices this way. On a gateway the gateway checks the role. The worker serves any relayed device stream: a relay arrives only on a gateway's uplink session, so it is trusted as far as that session is. The uplink message does not change. -3. **A running device streams.** A device in state `ready` or `leased` streams. Any other state (`provisioning`, `shutdown`, `reclaiming`, `quarantined`, `absent`, `deleted`) gets 409 `DEVICE_NOT_RUNNING`, with the device's state in the message. An unknown device gets 404 `UNKNOWN_DEVICE`. A driver that cannot capture keeps 415 `STREAM_NOT_SUPPORTED`. Every other refusal stays as it is. +3. **A running device streams.** A device in state `ready` or `leased` streams. Any other state (`provisioning`, `shutdown`, `reclaiming`, `quarantined`, `absent`, `deleted`) gets 409 `DEVICE_NOT_RUNNING`, with the device's state in the message. An unknown device gets 404 `UNKNOWN_DEVICE`. An unknown worker (on a gateway, an id not in its fleet; on a worker, any id but its own) gets 404 `UNKNOWN_WORKER`, the code the other `/v1/workers/{id}` routes use. A driver that cannot capture keeps 415 `STREAM_NOT_SUPPORTED`. Every other refusal stays as it is. 4. **A stream outlives a lease grant, not a reclaim.** A device that gets leased while it is watched keeps streaming, a physical iPhone included. A stream ends when: - the viewer closes it; - the capture ends or goes silent past the existing bound, which is also how a warm simulator that shuts down ends its stream; @@ -34,6 +34,8 @@ sequenceDiagram C->>G: GET /v1/stream/workers/w1/devices/d1 alt not an operator G-->>C: 404 UNKNOWN_DEVICE + else unknown worker + G-->>C: 404 UNKNOWN_WORKER else operator G->>W: relay GET /stream/devices/d1 alt unknown device @@ -68,8 +70,9 @@ stateDiagram-v2 - **Keep the lease route beside the device route.** Rejected: only the console calls it, and it keeps a second access model (lease owners) to secure and test. - **The relay carries the caller's role.** Rejected: it changes the uplink message, and it only defends against a gateway that is already trusted with every lease. +- **A second, worker-only path `/v1/stream/devices/{deviceId}`.** Rejected: the console would build two URLs by mode, against ADR 0012's one console for one host and for a fleet. - **Reuse 415 for a device that is not running.** Rejected: 415 says the device can never stream, and 409 says it cannot stream now. ## Verification -The stream module's tests drive both routes with faked drivers: role, state and lifetime cases. The slow-lane stream tests move to the device routes. The feature's tasks name them. +The stream module's tests drive the route on a worker and through a gateway with faked drivers: role, state and lifetime cases. The slow-lane stream tests move to the device routes. The feature's tasks name them. From 0f9c3d823014ff504e889f378a0cf22c9f81da29 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 11:22:58 +0200 Subject: [PATCH 3/5] docs(adr): renumber to 0045; a device stream ends when its device stops running (#906) --- ...creen-streams-to-operators-not-a-leases.md} | 18 ++++++++++-------- docs/internal/adr/README.md | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) rename docs/internal/adr/{0044-a-devices-screen-streams-to-operators-not-a-leases.md => 0045-a-devices-screen-streams-to-operators-not-a-leases.md} (78%) diff --git a/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md b/docs/internal/adr/0045-a-devices-screen-streams-to-operators-not-a-leases.md similarity index 78% rename from docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md rename to docs/internal/adr/0045-a-devices-screen-streams-to-operators-not-a-leases.md index 382d7c7f..e083b3df 100644 --- a/docs/internal/adr/0044-a-devices-screen-streams-to-operators-not-a-leases.md +++ b/docs/internal/adr/0045-a-devices-screen-streams-to-operators-not-a-leases.md @@ -1,4 +1,4 @@ -# 0044. A device's screen streams to operators, not a lease's +# 0045. A device's screen streams to operators, not a lease's - **Status:** Proposed - **Date:** 2026-10-23 @@ -18,10 +18,11 @@ The console is the only caller (`ui/src/state/screen.ts:129`). An operator there 1. **The route names a worker and a device.** Every daemon serves `GET /v1/stream/workers/{workerId}/devices/{deviceId}`. A device id is unique only within one worker, so the path names the worker. A gateway relays the request to that worker over its uplink, to the worker's internal `/stream/devices/{deviceId}`. A worker answers the same public path as a fleet of one, as ADR 0012 has it: `{workerId}` is its own instance id, the id `worker.list` gives it. The console therefore builds one URL on either daemon. `GET /v1/stream/leases/{id}` is removed. Nothing published depends on it. 2. **Operators only.** An agent token gets 404 `UNKNOWN_DEVICE` for any device, so an agent cannot list a fleet's devices this way. On a gateway the gateway checks the role. The worker serves any relayed device stream: a relay arrives only on a gateway's uplink session, so it is trusted as far as that session is. The uplink message does not change. 3. **A running device streams.** A device in state `ready` or `leased` streams. Any other state (`provisioning`, `shutdown`, `reclaiming`, `quarantined`, `absent`, `deleted`) gets 409 `DEVICE_NOT_RUNNING`, with the device's state in the message. An unknown device gets 404 `UNKNOWN_DEVICE`. An unknown worker (on a gateway, an id not in its fleet; on a worker, any id but its own) gets 404 `UNKNOWN_WORKER`, the code the other `/v1/workers/{id}` routes use. A driver that cannot capture keeps 415 `STREAM_NOT_SUPPORTED`. Every other refusal stays as it is. -4. **A stream outlives a lease grant, not a reclaim.** A device that gets leased while it is watched keeps streaming, a physical iPhone included. A stream ends when: +4. **A stream outlives a lease grant, not the device's running state.** A device that gets leased while it is watched keeps streaming, a physical iPhone included. A stream ends when: - the viewer closes it; - - the capture ends or goes silent past the existing bound, which is also how a warm simulator that shuts down ends its stream; - - the device is reclaimed after a lease ends. The lease-end participant stops every capture on the ended lease's device before the device is erased. + - the capture ends or errors; + - a lease on its device ends: the lease-end participant stops every capture on the ended lease's device before the device is reset; + - the device stops running with no lease end. The worker stream module subscribes to `device.shutdown`, `device.absence-detected`, `device.quarantined` and `device.deleted` and stops every capture on that device. These events come after the fact, so the capture may run a moment on a stopped device. The silence bound cannot stand in: a simulator's capture repeats its last frame, and a physical device's never notices its own end (`vendor/screen-capture/Sources/simlock-screen-capture/FrameCapture.swift:241-244`, `SimulatorSource.swift:19-21`, `simlock-device-capture/DeviceScreenSource.swift:383-385`). 5. **One capture per viewer, as today.** Sharing one capture between viewers (#602) is left to that issue, keyed by device. 6. **No business events.** Watching changes no lease and no device, as before. The daemon log keeps recording each stream opened and refused. @@ -55,14 +56,15 @@ stateDiagram-v2 [*] --> Streaming: device ready or leased Streaming --> Streaming: device leased Streaming --> [*]: viewer closes - Streaming --> [*]: capture ends or goes silent (warm shutdown) - Streaming --> [*]: lease ends, device reclaimed + Streaming --> [*]: capture ends or errors + Streaming --> [*]: lease ends (before reset) + Streaming --> [*]: device shut down, absent, quarantined or deleted ``` ## Consequences -- `src/stream/AGENTS.md`'s "a capture never outlives its lease" becomes "a capture never outlives its device's reclaim". -- ADR 0040 names the lease route as an example of a module route. That example now names the device routes. The rule itself does not change. +- `src/stream/AGENTS.md`'s "a capture never outlives its lease" becomes "a capture never outlives its device's running state". +- ADR 0040 names the lease route as an example of a module route. That example goes stale; its README row notes it, and the rule itself does not change. - The console shows the screen in the device drawer. The lease drawer has no Screen tab. - The website's "Watching the screen" page describes the device routes and an operator token only. diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index 14807e21..14d87af6 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -78,8 +78,8 @@ the status is stale. | [0037](0037-the-console-acts-through-the-http-routes-on-base-ui.md) | The console acts through the HTTP routes, on Base UI | Accepted — not yet implemented | | [0038](0038-the-warm-pool-learns-its-kinds-from-leases-and-boots-for-every-queued-request.md) | The warm pool learns its kinds from leases, and boots for every queued request | Accepted — not yet implemented | | [0039](0039-joined-workers-refuse-their-own-http-listener.md) | Joined workers refuse their own HTTP listener | Accepted — not yet implemented | -| [0040](0040-every-http-route-is-declared-once-and-the-openapi-document-is-built-from-the-declarations.md) | Every HTTP route is declared once, and the OpenAPI document is built from the declarations | Accepted — not yet implemented | +| [0040](0040-every-http-route-is-declared-once-and-the-openapi-document-is-built-from-the-declarations.md) | Every HTTP route is declared once, and the OpenAPI document is built from the declarations | Accepted — not yet implemented; its stream-route example stale since 0045 | | [0041](0041-the-daemon-can-run-under-the-users-service-manager.md) | The daemon can run under the user's service manager | Accepted — not yet implemented | | [0042](0042-simlock-updates-itself-through-a-frozen-call-and-a-detached-updater.md) | Simlock updates itself through a frozen call and a detached updater | Accepted — not yet implemented; narrows 0009's `takes-requests` stage | | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part | -| [0044](0044-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Proposed | +| [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Proposed | From d00aa42eb82224abd6a783be8745416fb0b46178 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 12:13:43 +0200 Subject: [PATCH 4/5] docs(adr): accept 0045 (#906) --- .../0045-a-devices-screen-streams-to-operators-not-a-leases.md | 2 +- docs/internal/adr/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/internal/adr/0045-a-devices-screen-streams-to-operators-not-a-leases.md b/docs/internal/adr/0045-a-devices-screen-streams-to-operators-not-a-leases.md index e083b3df..b3809bd8 100644 --- a/docs/internal/adr/0045-a-devices-screen-streams-to-operators-not-a-leases.md +++ b/docs/internal/adr/0045-a-devices-screen-streams-to-operators-not-a-leases.md @@ -1,6 +1,6 @@ # 0045. A device's screen streams to operators, not a lease's -- **Status:** Proposed +- **Status:** Accepted — not yet implemented - **Date:** 2026-10-23 - **Issue:** [#906](https://github.com/callstackincubator/simlock/issues/906) - **Depends on:** ADR 0012 (a worker answers fleet routes as a fleet of one), ADR 0030 (stream is an optional module), ADR 0031 (screen-source seam), ADR 0039 (joined workers serve no HTTP of their own). diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index 14d87af6..2ca8be20 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -82,4 +82,4 @@ the status is stale. | [0041](0041-the-daemon-can-run-under-the-users-service-manager.md) | The daemon can run under the user's service manager | Accepted — not yet implemented | | [0042](0042-simlock-updates-itself-through-a-frozen-call-and-a-detached-updater.md) | Simlock updates itself through a frozen call and a detached updater | Accepted — not yet implemented; narrows 0009's `takes-requests` stage | | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part | -| [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Proposed | +| [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented | From 729804c8e4268da0115955f56161bccb9b3f9b40 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 20:21:43 +0200 Subject: [PATCH 5/5] docs(adr): order the index rows 0044 before 0045 after the merge (#906) --- docs/internal/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internal/adr/README.md b/docs/internal/adr/README.md index ddb0aafa..757b4f4e 100644 --- a/docs/internal/adr/README.md +++ b/docs/internal/adr/README.md @@ -82,5 +82,5 @@ the status is stale. | [0041](0041-the-daemon-can-run-under-the-users-service-manager.md) | The daemon can run under the user's service manager | Accepted — not yet implemented | | [0042](0042-simlock-updates-itself-through-a-frozen-call-and-a-detached-updater.md) | Simlock updates itself through a frozen call and a detached updater | Accepted — not yet implemented; narrows 0009's `takes-requests` stage | | [0043](0043-physical-devices-reach-agent-device-through-a-second-daemon.md) | Physical devices reach agent-device through a second daemon on the worker | Accepted — not yet implemented; supersedes 0023 §3–4 in part | -| [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented | | [0044](0044-the-console-reads-what-its-page-shows-and-events-mark-it-stale.md) | The console reads what its page shows, and events mark it stale | Accepted — not yet implemented; supersedes 0013 §3 | +| [0045](0045-a-devices-screen-streams-to-operators-not-a-leases.md) | A device's screen streams to operators, not a lease's | Accepted — not yet implemented |