Skip to content

Stream any running device to an operator by worker and device id #909

Closed

Description

@V3RON

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
Loading

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
Loading
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)
Loading

Contract and event changes

  • HTTP: route GET /v1/stream/workers/{workerId}/devices/{deviceId}, declared in src/stream/definition.ts beside the lease route. Statuses: 200 (the simlock-stream-* headers as today; no x-simlock-lease, which nothing reads: rg x-simlock-lease ui src finds only service.ts and definition.ts), 404 UNKNOWN_DEVICE and UNKNOWN_WORKER, 405 BAD_REQUEST, 409 DEVICE_NOT_RUNNING, 415, 500, 502, 503 as today. Path segments are URL-decoded.
  • DEVICE_NOT_RUNNING is a stream refusal in src/stream/refusal.ts (codes there are free strings), added to that file's list of codes it raises; src/contract/errors.ts does 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.
  • WorkerContext gains instanceId: string (module contract, not the wire).
  • The uplink's relay.request is unchanged (F6): the device route sends no leaseId.
  • 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 leased and not ready (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 on device.shutdown, device.absence-detected, device.quarantined or device.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.md Rules: "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:103 documents the device route and operator tokens; src/stream/AGENTS.md changes 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, physical absent → 409 DEVICE_NOT_RUNNING with the state in the message; unknown device → 404 UNKNOWN_DEVICE; another worker id → 404 UNKNOWN_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 → 503 WORKER_UNREACHABLE; the worker's 409 DEVICE_NOT_RUNNING and 404 UNKNOWN_DEVICE arrive with their codes.
  • A watched device changes: a ready device 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.quarantined or device.deleted for 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. Extends create.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 — a warmPool.targets entry keeps one simulator ready with no lease (an iOS reclaim ends in shutdown, 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 test and pnpm test:e2e src/daemon/stream-gateway.e2e.test.ts pass, with the first four Tests lines.
  • Slow lane: scripts/slow-e2e.sh e2e/slow-ios-stream.test.ts e2e/slow-android-stream.test.ts passes 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.ts finds nothing.
  • The OpenAPI document lists GET /v1/stream/workers/{workerId}/devices/{deviceId} with its statuses.
  • watching-the-screen.mdx documents the device route, for operator tokens.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 23, 2026
  3. self-assigned this
    on Oct 23, 2026
  4. 4 remaining items

  5. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    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 model iPhone 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 923 or re-run /deliver 906. The PR carries needs-hardware.

    Written by an agent.

  6. added
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    and removed
    task:readySpec checked. An agent may implement it once its Depends on allow.
    on Oct 23, 2026
  7. removed their assignment
    on Oct 23, 2026
  8. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:blockedAgent stopped on a blocker in its handoff. Maintainer re-adds the label it came from.
    on Oct 23, 2026
  9. self-assigned this
    on Oct 23, 2026
  10. added 2 commits that reference this issue on Oct 23, 2026
    34d65a3
    3080eca
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions