Repository navigation
Stream any running device to an operator by worker and device id #909
Description
Part of #906.
Scope
An operator can stream the screen of any running device, leased or not, through GET /v1/stream/workers/{workerId}/devices/{deviceId}. A single worker answers it for its own id; a gateway relays it to the named worker. A stream keeps running when its device is leased, stops before the device is reset after a lease, and stops when the device stops running with no lease end (shut down, absent, quarantined, deleted). The lease route GET /v1/stream/leases/{id} stays until #910 moves the console off it and removes it. The slow-lane stream tests move to the new route.
In short
Today a picture is asked for by lease id, so a device with no lease has none. After this PR it is asked for by worker id and device id, by an operator only, and a ready or leased device answers with its picture. Any other device state answers 409.
flowchart LR
C[Operator] -->|"GET /v1/stream/workers/w1/devices/d1 (new)"| G[Gateway]
G -->|"relay /stream/devices/d1 (new)"| W[Worker]
C -->|same path, w1 = its own id| W
W -->|"state ready or leased? (new)"| D[Driver capture]
L[Lease ends] -->|"reclaim hook: stop captures on its device (changed)"| D
Before and after, with an operator token on a worker whose id is w1:
$ curl -H "authorization: Bearer $OP" localhost:8787/v1/stream/workers/w1/devices/dev_ready
today: 404 {"error":{"code":"UNKNOWN_LEASE","message":"This path names no stream lease"}}
after: 200, simlock-stream-codec: h264, the frames
$ curl … /v1/stream/workers/w1/devices/dev_off (state shutdown)
after: 409 {"error":{"code":"DEVICE_NOT_RUNNING","message":"The device is shutdown; only a ready or leased device streams"}}
| Caller, request | Today | After |
|---|---|---|
Operator, worker w1, dev_ready (ready) |
404 UNKNOWN_LEASE (no such path) |
200 |
Operator, dev_leased (leased) |
404 (only by lease id) | 200 |
Operator, a device in shutdown, provisioning, reclaiming, quarantined or absent |
404 | 409 DEVICE_NOT_RUNNING, message The device is <state>; only a ready or leased device streams |
Operator, dev_nope |
404 | 404 UNKNOWN_DEVICE, Unknown device: dev_nope |
Operator on worker w1, path names w2 |
404 | 404 UNKNOWN_WORKER, Unknown worker: w2 |
| Operator on a gateway, a worker id not in its fleet | 404 | 404 UNKNOWN_WORKER |
| Agent token, the device its lease holds | 404 | 404 UNKNOWN_DEVICE |
| Operator, a device whose driver cannot capture | — | 415 STREAM_NOT_SUPPORTED, as today for a lease |
"Running device": state ready (booted and idle, warm pool included) or leased.
If this goes wrong, a user would see the picture stop when an agent leases the device being watched, or a device reset while still being filmed.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | The only stream route is GET /v1/stream/leases/{id}, declared as module route data with statuses 200, 404, 405, 415, 500, 502, 503 |
src/stream/definition.ts:24-66 |
| F2 | Module routes are mounted at /v1/<prefix> and /v1/<prefix>/* behind agentAuth, so /v1/stream/workers/… reaches the stream mount |
src/http/app.ts:762-766 |
| F3 | leaseIdOf matches only ^/v1/stream/leases/([^/]+)$; anything else answers noSuchStream (404 UNKNOWN_LEASE) |
src/stream/service.ts:17-26,34; src/stream/refusal.ts:25-28 |
| F4 | The HTTP mount maps the identity's role to operator or agent; a relayed request is always served as agent with the gateway's tenant |
src/stream/mounts.ts:17-44 |
| F5 | A relay is admitted only from a gateway's uplink session | src/stream/mounts.ts:34-37; src/contract/operations.ts:1051-1056 |
| F6 | relay.request carries url (http or https, ≤ 4096 characters), tenant (1–300 characters) and an optional leaseId; http://simlock-worker/stream/devices/dev_1 and tenant gw:<instance>:<principal> pass |
src/contract/operations.ts:1038-1075 |
| F7 | The worker hands a relay to the target with (request, tenant, leaseId) |
src/daemon/relay.ts:221 |
| F8 | The worker link looks up the lease, checks owner unless operator, registers the capture by lease id before opening, re-reads the lease after the first frame | src/stream/worker-link.ts:45-85,113-130 |
| F9 | The driver opens a screen from a platform and a device, no lease | src/stream/worker-link.ts:26-33; src/core/driver-catalog.ts:96-106 |
| F10 | Captures are a map from lease id to slots; stop(leaseId) aborts and awaits each close |
src/stream/captures.ts:37,91-99 |
| F11 | The reclaim hook (beforeDeviceReclaim) calls captures.stop(ended.leaseId); ModuleEndedLease carries deviceId; the hook runs after the lease-end commit, before reclaim |
src/stream/captures.ts:102-110; src/modules/types.ts:43-49,98; src/leasing/lease-release-coordinator.ts:386-387; src/modules/host.ts:236 |
| F12 | Device states: shared ready, leased, reclaiming, quarantined; virtual adds provisioning, shutdown, deleted; physical adds absent, deleted. A ready device can go to shutdown (virtual) or absent (physical) with no lease |
src/core/domain.ts:202-206,412,422 |
| F13 | The warm pool shuts down a never-leased device ready longer than idle.shutdownAfterMs |
src/core/warm-pool/AGENTS.md:53 |
| F14 | A capture does not end by itself when its device stops: a simulator's capture repeats its last frame on an idle timer, so the 30 s silence bound never fires; the simulator source cannot notice its own end; a physical iPhone's source does not end on silence | vendor/screen-capture/Sources/simlock-screen-capture/FrameCapture.swift:241-244; SimulatorSource.swift:19-21; vendor/screen-capture/Sources/simlock-device-capture/DeviceScreenSource.swift:383-385; src/stream/feed.ts:23,47 |
| F23 | A worker module can subscribe to business events through ctx.events.subscribe |
src/modules/types.ts:150-152; src/daemon/main.ts:506 |
| F24 | device.shutdown, device.absence-detected, device.quarantined and device.deleted carry the device id and fire when a device stops, goes absent, is quarantined or is deleted |
docs/internal/EVENTS.md:83,87,88,90 |
| F15 | UNKNOWN_DEVICE is 404, its message Unknown device: <id>; UNKNOWN_WORKER is 404, Unknown worker: <id> |
src/contract/errors.ts:324,441; src/core/registry.ts:147; src/gateway/worker-registry.ts:617 |
| F16 | The gateway link resolves a lease through the fleet lease index and relays to /stream/leases/<worker lease>; ctx.worker(id) returns a worker's view or nothing; ctx.relay(workerId, request, tenant, leaseId?) relays |
src/stream/gateway-link.ts:33-62; src/stream/gateway.ts:9-24; src/modules/types.ts:185-200 |
| F17 | A relay to a disconnected worker fails WORKER_UNREACHABLE; a worker without the target, RELAY_TARGET_MISSING, which the stream maps to 503 STREAM_UNAVAILABLE |
src/gateway/relay.ts:300-302; src/daemon/relay.ts:105; src/stream/gateway-link.ts relayed |
| F18 | A worker's id is its instance id, loaded before the module host is built; WorkerContext has no instance id today |
src/daemon/main.ts:407,494-520; src/daemon/dispatcher.ts:282-284; src/modules/types.ts:131-153 |
| F19 | The success response carries x-simlock-lease: <leaseId> |
src/stream/service.ts:85; src/stream/definition.ts:42 |
| F20 | The gateway's own device view may hold only physical devices, so the gateway cannot check a virtual device itself | src/gateway/worker-registry.ts:160-176 |
| F21 | Tests: create.test.ts builds createWorkerStream with TestRegistry and ScriptedFeed; TestRegistry devices have no state today; the fast gateway e2e runs a real gateway and worker with a scripted capture |
src/stream/create.test.ts:1-40; src/stream/test-fixtures.ts:19-50; src/daemon/stream-gateway.e2e.test.ts:120-160 |
| F22 | Slow-lane tests that call the lease route: e2e/slow-ios-stream.test.ts:78, slow-android-stream.test.ts:93, slow-ios-physical.test.ts:494,602, slow-android-physical.test.ts:190, slow-ios-physical-manual.test.ts |
rg -l "stream/leases" e2e |
Modules touched
Searches: rg "stream/leases|leaseIdOf|requireLease|captures.stop|createStreamLeaseEnd|WorkerContext|unknownLease|noSuchStream" over src, e2e, docs/internal, website/src/content/docs.
flowchart LR
H[http app: module mount] --> M[stream/mounts, service *]
M --> WL[stream/worker-link *]
M --> GL[stream/gateway-link, gateway.ts *]
WL --> CA[stream/captures *]
WL --> R[core registry snapshot]
WL --> DC[core DriverCatalog.openScreen]
GL --> GR[gateway relay]
MH[modules/types, host: WorkerContext.instanceId *] --> W[stream/worker.ts *]
DM[daemon/main.ts *] --> MH
sequenceDiagram
participant C as Operator
participant G as Gateway stream
participant W as Worker stream
participant D as Driver
C->>G: GET /v1/stream/workers/w1/devices/d1
G->>G: role operator? worker w1 known?
G->>W: relay.request url /stream/devices/d1, tenant gw:inst:principal
W->>W: device d1 in registry? state ready or leased?
W-->>G: 409 DEVICE_NOT_RUNNING (shutdown)
G-->>C: 409 DEVICE_NOT_RUNNING, code kept
W->>D: openScreen(platform, d1)
D-->>W: meta, frames
W-->>C: 200 frames (relayed unread)
Contract and event changes
- HTTP: route
GET /v1/stream/workers/{workerId}/devices/{deviceId}, declared insrc/stream/definition.tsbeside the lease route. Statuses: 200 (thesimlock-stream-*headers as today; nox-simlock-lease, which nothing reads:rg x-simlock-lease ui srcfinds onlyservice.tsanddefinition.ts), 404UNKNOWN_DEVICEandUNKNOWN_WORKER, 405BAD_REQUEST, 409DEVICE_NOT_RUNNING, 415, 500, 502, 503 as today. Path segments are URL-decoded. DEVICE_NOT_RUNNINGis a stream refusal insrc/stream/refusal.ts(codes there are free strings), added to that file's list of codes it raises;src/contract/errors.tsdoes not change.- On the relay door the worker serves a device stream whatever the relayed tenant: the gateway already refused non-operators, and a relay comes only from a gateway's uplink session (ADR 0045 §2, F5). The role check is on the HTTP door only.
WorkerContextgainsinstanceId: string(module contract, not the wire).- The uplink's
relay.requestis unchanged (F6): the device route sends noleaseId. - No protocol bump: no daemon contract shape changes. No events (ADR 0045 §6).
Other code on the same state
The capture registry and the device records. Searched rg "captures\.(begin|stop)|beforeDeviceReclaim|leaseEnded" src (8 hits) and rg "snapshot.devices" src/stream (2). Components:
- The reclaim hook (lease end): stops every capture on the ended lease's device, lease and device captures alike; the reclaim waits for those closes, as today. If a viewer opens a device stream while the hook runs, the device's state is already not
leasedand notready(reclaiming), so it gets 409. - The warm pool's shutdown, the reaper, doctor and a physical device going
absent, quarantined or deleted: no lease end, so the module stops the device's captures ondevice.shutdown,device.absence-detected,device.quarantinedordevice.deleted(F14, F23, F24). The event follows the change, so a capture may run briefly on a stopped device; nothing here delays the shutdown. - A lease granted while streaming: no hook runs; the stream keeps running.
- Console: watch a device from its drawer's Screen tab; the lease stream route goes #910 removes the lease route and its capture keying; this task keeps both working.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
Gateway → ctx.relay |
ok | worker's answer relayed, code kept | relay inactivity bound, as today |
Gateway → ctx.relay |
worker disconnected | 503 WORKER_UNREACHABLE |
— |
Gateway → ctx.relay |
worker has no stream target | 503 STREAM_UNAVAILABLE |
— |
Worker → openScreen |
ScreenNotSupportedError |
415 | — |
Worker → openScreen |
other error | 503 STREAM_UNAVAILABLE |
— |
| Worker → first frame | none | 503, capture closed | 30 s from the open |
| Worker → next frame | none | stream ends | 30 s from the last byte |
| Gateway → a worker on an older build | answers 404 UNKNOWN_LEASE "This path names no stream lease" (no leaseId on the relay) |
forwarded with its code, as any relayed refusal; the console shows the message. Ends once the worker is updated | — |
| Worker → reclaim during open | slot stopped | 409 DEVICE_NOT_RUNNING, capture closed |
— |
| Worker → device event (shutdown, absent, quarantined, deleted) | received | every capture on that device stopped; the stream ends | — |
| Worker → device event during open | received | 409 DEVICE_NOT_RUNNING, capture closed |
— |
State: captures live in memory only; a restart ends every stream with its process. Two viewers on one device get two captures (ADR 0045 §5).
Rules in play
- ADR 0045 §1–§6: the route, operator only, running states, refusals, lifetime (including the four device events), no events emitted — this task builds all six on the server.
- ADR 0012 §1: "the worker itself" — the worker answers only its own instance id.
- ADR 0030: the stream stays an optional module; the new context field is added for every module.
src/stream/AGENTS.mdRules: "Ownership before any work; unknown and not-yours answer the same" — role, worker and device are checked before a capture opens; an agent gets the same 404 for any device.- documentation rule 9:
website/src/content/docs/docs/using-devices/watching-the-screen.mdx:103documents the device route and operator tokens;src/stream/AGENTS.mdchanges its route, its rule "a capture never outlives its lease" to "its device's running state", and its files table.
Tests
Seam: createWorkerStream and createGatewayStream through their mount and relay target (model: src/stream/create.test.ts, with TestRegistry gaining a device state), and the fast gateway e2e src/daemon/stream-gateway.e2e.test.ts.
- An operator asks a single worker for a device's stream:
ready→ 200 frames;leased→ 200;shutdown,provisioning,reclaiming,quarantined, physicalabsent→ 409DEVICE_NOT_RUNNINGwith the state in the message; unknown device → 404UNKNOWN_DEVICE; another worker id → 404UNKNOWN_WORKER; driver cannot capture → 415; nothing opened for any refusal. - An agent token asks for a device's stream: on a worker, for the device its lease holds → 404
UNKNOWN_DEVICE; through a gateway, same; nothing opened. - An operator asks a gateway for a device's stream: relayed → 200 frames; unknown worker → 404
UNKNOWN_WORKER; disconnected worker → 503WORKER_UNREACHABLE; the worker's 409DEVICE_NOT_RUNNINGand 404UNKNOWN_DEVICEarrive with their codes. - A watched device changes: a
readydevice is leased → the stream keeps sending frames; that lease ends → the capture is closed before the reclaim hook returns; the hook runs while the capture is still opening → 409, capture closed;device.shutdown,device.absence-detected,device.quarantinedordevice.deletedfor the device → its captures stopped and the stream ends; the same event for another device → its stream untouched; the capture ends → the stream ends; the viewer aborts → the capture is closed once. Extendscreate.test.ts's "stops a relayed capture when its lease ends". - A real unleased simulator streams, and keeps streaming when leased: slow lane,
e2e/slow-ios-stream.test.ts— awarmPool.targetsentry keeps one simulatorreadywith no lease (an iOS reclaim ends inshutdown, so lease-and-release cannot); it answers frames; a lease is granted for that kind mid-stream, the test checks the grant names the watched device id, and frames keep coming for 10 s; the other slow stream tests (F22) call the device route.
Done when
pnpm testandpnpm test:e2e src/daemon/stream-gateway.e2e.test.tspass, with the first four Tests lines.- Slow lane:
scripts/slow-e2e.sh e2e/slow-ios-stream.test.ts e2e/slow-android-stream.test.tspasses on this machine. The physical-device stream tests are moved to the device route; their run is not required (no phone on this machine). rg -l stream/leases e2e/slow-*.test.tsfinds nothing.- The OpenAPI document lists
GET /v1/stream/workers/{workerId}/devices/{deviceId}with its statuses. watching-the-screen.mdxdocuments the device route, for operator tokens.
Out of scope
- Removing
GET /v1/stream/leases/{id}and the console change (Console: watch a device from its drawer's Screen tab; the lease stream route goes #910). - Sharing one capture between viewers (One capture per lease, shared by every viewer of its screen #602).
- Any change to how a physical iPhone captures.
Depends on
Written by an agent.
Activity
feat(stream): a gateway relays a device's stream from a worker (#909)
test: an operator asks a single worker for a device's stream (#909)
feat(stream): a worker streams a ready or leased device and refuses a…
test: an agent token asks for a device's stream (#909)
feat(stream): only an operator streams a device (#909)
test: an operator asks a gateway for a device's stream (#909)
feat(stream): a gateway refuses an unknown worker and keeps a worker'…
test: a watched device changes: leased, lease ended, hook while openi…
feat(stream): a lease ending stops the captures on its device and a h…
test: a device that stops running ends its streams (#909)
4 remaining items
test(e2e): list the warm simulator through the CLI (#909)
test(e2e): list the warm simulator through the CLI (#909)
test(stream): a device stopped while its first frame is awaited refus…
test(e2e): a stream kept after the lease is still open at the end of …
V3RON commentedon Oct 23, 2026
Handoff
Done: PR #923 is built, audited and reviewed (round 2 passed, one test-logic finding fixed). It is ready and sits in the #906 stack (#908 → #923 → #924 → #928). Its head is c91d3c0, a clean merge of main through the ADR branch.
Not done: the slow-lane Done when line,
scripts/slow-e2e.sh e2e/slow-ios-stream.test.ts e2e/slow-android-stream.test.ts. The new unleased-simulator test assumes the warm-pool modeliPhone 16.Findings: none open.
Blocked on: disk space on the delivery Mac. The lane refused to start twice (12.9 and 14.3 GiB free, it needs 15). Free the space, then run
/verify-hardware 923or re-run/deliver 906. The PR carriesneeds-hardware.Written by an agent.
test(e2e): the stream lanes read the worker's instanceId, and the And…
feat(stream): stream any running device by worker and device id (#923)
feat(history): the worker's Events tab scrolls back through its histo…