Skip to content

A lease holder streams its lease's screen #1015

Closed

Description

@V3RON

Part of #1013.

Scope

A lease holder watches its own lease's screen with its agent token, on a standalone worker and through a gateway, at GET /v1/leases/{id}/stream. Every stream that a lease end stops now says why in its last frame, and the console shows that reason. This task also adds the lease route seam (Shared values: Lease route on #1013) that #1017 reuses.

In short

An agent token can now open GET /v1/leases/L-42/stream for a lease it owns and get the same frames the device route gives an operator (F1, F7). Another agent's lease and an unknown lease answer 404 UNKNOWN_LEASE with Unknown lease: <id>, as GET /v1/leases/{id} does (F3, F19). When the lease ends, the last frame on every stream of that device is a new tag 0x07 holding {"reason":"explicit"} (or killed, expired, device-lost), then the body ends. A leased phone that drops off also gets device-lost. The console says why, in the words of Shared values: ended frame on #1013, and not as an error.

flowchart LR
  C[Caller, agent token] --> H["http: /v1/leases/{id}/stream (NEW)"]
  H -->|lease.get: owner or operator, else 404| S[stream module lease handler NEW]
  S -->|worker| Cap[captures by device]
  S -->|gateway: relay with leaseId| W[worker relay target /stream/leases/&lt;workerLeaseId&gt; NEW]
  W --> Cap
  E[lease ends] -->|0x07 frame, then end CHANGED| Cap
Loading

Before and after, through a gateway:

$ curl -s -H "Authorization: Bearer $AGENT" https://gw:7480/v1/leases/L-42/stream -o /dev/null -w '%{http_code}\n'
404                                   # before: no such route
200                                   # after: the lease's picture
$ curl -s -H "Authorization: Bearer $OTHER_AGENT" https://gw:7480/v1/leases/L-42/stream
{"error":{"code":"UNKNOWN_LEASE","message":"Unknown lease: L-42"}}
Request Today After
agent, own lease 404 200
agent, another's lease 404 404 UNKNOWN_LEASE
unknown lease 404 404 UNKNOWN_LEASE
operator, any lease 404 200
capabilities.stream.enabled false on the answering daemon 404 404, as any unrouted path
agent, device route 404 UNKNOWN_DEVICE 404 UNKNOWN_DEVICE (unchanged)

If this goes wrong, a user would notice an agent seeing another agent's screen, or a picture that stops with no reason.

Technical spec

Facts

Commit e1f2f43.

# Fact Source
F1 The only stream route is the device route, and only an operator may use it: an agent gets 404 UNKNOWN_DEVICE on the worker and on the gateway src/stream/definition.ts:25-29, src/stream/worker-link.ts:118-120, src/stream/gateway-link.ts:39-41
F2 Module routes mount only at /v1/<prefix> and /v1/<prefix>/*, behind agentAuth; the prefix is the module's name src/http/app.ts:781-785, src/modules/host.ts:272-275, src/modules/host.ts:377-381
F3 GET /v1/leases/{id} resolves through lease.get, which answers 404 UNKNOWN_LEASE alike for an unknown lease and one the caller does not own; an operator reads every lease src/http/app.ts:803-811, src/http/lease-declarations.ts:295-303
F4 A gateway's lease record carries workerId and workerLeaseId, the worker's own lease id src/contract/schemas.ts:1386-1390, src/gateway/dispatcher.ts:547-554
F5 A gateway module relays with an optional lease id: relay(workerId, request, tenant, leaseId?), and the worker target receives (request, tenant, leaseId) src/modules/types.ts:202, src/modules/host.ts:223-229, src/modules/types.ts:60
F6 The worker's relay target serves relayed requests as an operator, trusting the gateway's check src/stream/mounts.ts:37-46
F7 Captures are keyed by device; a lease end calls stopOnDevice(deviceId), which aborts each slot and closes it, and reads no reason src/stream/captures.ts:20-25, src/stream/captures.ts:96-103, src/stream/captures.ts:112-125, src/stream/worker.ts:18
F8 The lease end the stream hook gets carries reason: explicit, killed, expired or device-lost src/modules/types.ts:43-49
F9 A device stopping (shutdown, absence, quarantine, delete) also stops its captures, with no lease end src/stream/create.ts:16-21, src/stream/create.ts:64-71
F10 A stream body ends by closing the feed and the controller; no final frame is written src/stream/feed.ts:108-113
F11 Frame tags are 0x01–0x06; a frame is a 4-byte big-endian length, a tag byte and the payload src/core/screen.ts:7-18, src/core/screen.ts:71-77
F12 The console skips a tag it does not know, handles tag 5 as screen meta, and tells its sink onEnd(reason) once ui/src/data/stream.ts:228-237, ui/src/data/stream.ts:66-74
F13 The console's test pins its tag table to SCREEN_FRAME_TAG ui/src/data/stream.test.ts:12, ui/src/data/stream.test.ts:625
F14 The worker answers a relayed path /stream/devices/<id>; any other path is no stream src/stream/service.ts:32-36, src/stream/service.ts:52
F15 An unrouted path gets Hono's default 404: src/http registers no not-found handler git grep -n notFound e1f2f43e6 -- 'src/http/*.ts' → no match
F16 The worker's registry view lists its leases, so a module can check a lease against the worker's own record src/modules/types.ts:113-116
F17 The website says the stream is operator-only and view-only, and lists what agents may do website/src/content/docs/docs/using-devices/watching-the-screen.mdx:8, :12, :69-71; website/src/content/docs/docs/gateway/access-control.mdx:16
F18 Module routes enter the OpenAPI document from each definition's http.routes src/stream/definition.ts:22-29, src/http/openapi.ts:26
F19 UNKNOWN_LEASE reads Unknown lease: <id> on a worker and on a gateway src/core/registry.ts:154, src/gateway/dispatcher.ts:571
F20 When a leased physical device goes absent, the registry commits the lease end, then emits device.absence-detected with previousState: "leased"; the bus runs subscribers at once, so the stream's device-stop subscriber runs before the lease-end participants are called with device-lost src/core/registry.ts:579-589, src/bus/index.ts:512-516, src/stream/create.ts:67-70, src/leasing/lease-release-coordinator.ts:170-173
F21 The health monitor's lost device ends the lease through the participants with device-lost, with no device-stop event first src/leasing/lease-release-coordinator.ts:112-113
F22 The console passes the reader's end reason straight to the viewer, and the reader stores that reason as the stream's error ui/src/state/screen.ts:175-176, ui/src/data/stream.ts:119-126
F23 A capture whose slot is stopped before its first frame answers 409 DEVICE_NOT_RUNNING "The device stopped before its picture started" src/stream/worker-link.ts:72-89, src/stream/refusal.ts:39-41
F24 A gateway passes a worker's refusal on with the worker's code and message src/stream/refusal.ts:64-82
F25 A worker answers a relayed path it does not know with 404 UNKNOWN_DEVICE "This path names no stream" src/stream/service.ts:32-36, src/stream/service.ts:52, src/stream/refusal.ts:44-48

Modules touched

Searches: mount.prefix, HttpMount, ModuleMount, beginOnDevice, stopOnDevice, relayedDeviceTargetOf, SCREEN_FRAME_TAG, onEnd( across src/, ui/src/ and website/.

  • src/http/app.ts: the lease route: /v1/leases/{id}/<module name> for each loaded module that has a lease handler, behind agentAuth; resolves the lease with lease.get (F3), then calls the handler with (request, identity, lease).
  • src/modules/types.ts, src/modules/host.ts: WorkerModule.leaseRoute and GatewayModule.leaseRoute, collected like mounts (F2). The host refuses to load a module whose name is a core lease sub-route (renew, exec), as it refuses a name that clashes with a core first segment; the core route wins (decided in delivery, pending the maintainer).
  • src/stream/: the lease handler on worker and gateway, the relayed path /stream/leases/<workerLeaseId>, the ended frame on lease end, the route declaration.
  • src/core/screen.ts: tag ended: 0x07.
  • ui/src/data/stream.ts: on 0x07, onEnd with the reason.
  • src/stream/AGENTS.md, src/http/AGENTS.md, website pages in F17.
flowchart LR
  http[http app CHANGED] --> host[modules host CHANGED]
  host --> stream[stream module CHANGED]
  stream --> screen[core/screen CHANGED]
  ui[console stream reader CHANGED] --> screen
  stream -->|gateway relay| relay[gateway relay]
Loading
sequenceDiagram
  participant C as Agent
  participant G as Gateway http
  participant SG as stream (gateway)
  participant W as Worker stream
  C->>G: GET /v1/leases/L-42/stream
  G->>G: lease.get L-42 for the caller
  alt not the caller's, or unknown
    G-->>C: 404 UNKNOWN_LEASE
  else
    G->>SG: leaseRoute(request, identity, lease)
    SG->>W: relay /stream/leases/w1-7 (leaseId w1-7)
    alt lease gone on the worker
      W-->>C: 404 UNKNOWN_LEASE
    else worker unreachable
      SG-->>C: 503 WORKER_UNREACHABLE
    else
      W-->>C: frames … 0x07 {"reason"} , end
    end
  end
Loading

Contract and event changes

None: no src/contract/ shape changes (searched src/contract for stream, lease.get). The HTTP surface gains GET /v1/leases/{id}/stream, declared in src/stream/definition.ts so the OpenAPI document lists it (F18), with 200 (the stream's content types and headers, as the device route), 404 UNKNOWN_LEASE, 409 DEVICE_NOT_RUNNING, 415 STREAM_NOT_SUPPORTED, 500 INTERNAL, 502 and 503 as the device route. The ended frame payload is JSON {"reason": string}, one of the four values in F8, at most 64 bytes; a reader that cannot parse it ends with no reason. No event changes.

Other code on the same state

Searches: stopOnDevice (3 hits: captures.ts, create.ts, captures.ts lease end), beginOnDevice (2 hits: captures.ts, worker-link.ts), registry.snapshot.leases in src/stream (0 hits).

  • Device-stop events (F9) and lease end (F7) both call stopOnDevice. Lease end writes 0x07 with its reason, then ends. device.absence-detected with previousState: "leased" also writes 0x07 {"reason":"device-lost"}: on that path the lease end is already committed and its participants run after the event (F20). Every other device stop ends with no frame. A slot already stopped writes nothing more, so the lease end that follows an absence adds no second frame.
  • The device route and the lease route share the capture table by device: a lease end stops both, as today.
  • A lease route open races its own lease end: the handler registers its slot (beginOnDevice) first, then checks the lease is still in registry.snapshot.leases (F16), then opens. A lease that ended before the check answers 404 UNKNOWN_LEASE; one that ends after is stopped by stopOnDevice with the 0x07 frame.
  • Input socket for a lease #1017 adds a lease socket on the same lease route path resolution (Shared values: Lease socket on Let a lease holder watch and interact with its device's screen #1013).

Failure modes

Call Outcome What happens Limit, counted from
http → lease.get ok the lease goes to the module's lease handler as GET /v1/leases/{id} today
http → lease.get UNKNOWN_LEASE 404 UNKNOWN_LEASE, no module called —
gateway stream → relay to the worker worker unreachable 503 WORKER_UNREACHABLE (F5, src/stream/gateway-link.ts:56-75) the relay's deadline, as the device route
gateway stream → relay worker has no stream module 503 STREAM_UNAVAILABLE naming the worker, as the device route —
worker lease target lease not in the registry 404 UNKNOWN_LEASE —
worker lease target capture opens frames, then 0x07 on lease end the 30 s first-frame and silence bounds of today
worker lease target the lease ends before the first frame 409 DEVICE_NOT_RUNNING, as today (F23); no body exists to carry 0x07 the 30 s first-frame bound
gateway stream → relay the worker answers 404 UNKNOWN_LEASE for its own lease id 404 UNKNOWN_LEASE with Unknown lease: <gateway lease id>: the gateway puts back the id the caller asked for, instead of passing the worker's message (F24) —
gateway stream → relay a worker of an older Simlock answers the lease path 404 UNKNOWN_DEVICE (F25) passed on as is; no test: a fleet runs one version, and this ends with the update —

State written: none persisted. A daemon restart ends every stream as today.

Rules in play

  • Architecture rule 10, "A rule is enforced in exactly one place": the ownership check lives only in the http lease route (lease.get); the stream module never checks owners.
  • Architecture rule 15, "Dependencies point from the optional to the required": http knows only module names and a lease handler, never stream.
  • Architecture rule 21, "A host never names an implementation": the lease route matches a loaded module's name, with no stream literal in src/http.
  • Documentation rule 9, "A change to the public surface updates the website in the same change": the pages in F17 and the OpenAPI declaration (F18).
  • ADR 0045 §4 (lifetime) stays; ADR 0049 §1, §2 and §6 are what this task builds.

Tests

Seam: src/daemon/stream-gateway.e2e.test.ts (a real gateway and worker over HTTP, the screen faked), extended with a standalone worker daemon; the console row on ui/src/data/stream.test.ts. The "lease gone on the worker" case cannot be held open between two real daemons (the gateway's own lease.get answers first once its view refreshes), so it runs in src/stream/gateway-link.test.ts with a relay that answers as that worker; its 404 message names the gateway lease id. The device-lost cases drive both F20 (a phone marked absent) and F21 (the health monitor).

  • A caller GETs /v1/leases/{id}/stream on a standalone worker and gets the picture or a refusal: own lease 200; another's 404 UNKNOWN_LEASE; unknown 404; operator 200; stream capability off 404.
  • A caller GETs /v1/leases/{id}/stream on a gateway and gets the lease's worker's picture or a refusal: own lease 200; another's 404 UNKNOWN_LEASE; worker unreachable 503 WORKER_UNREACHABLE; worker without the stream module 503 STREAM_UNAVAILABLE; lease gone on the worker 404 UNKNOWN_LEASE; and, at the worker's own stream lease handler, a /stream/leases/<id> request for a lease missing from the worker's registry answers 404 UNKNOWN_LEASE (decided in delivery, pending the maintainer).
  • A viewer of either stream route sees a 0x07 frame with the reason, then the end, when the lease ends: explicit, expired, killed; device-lost by the health monitor and by physical absence; lease route and device route; a device that was not leased going absent writes no 0x07 frame (decided in delivery, pending the maintainer). The 0x07 cases may run at the stream module's surface (src/stream/create.test.ts) where the device-lost cases need a real health monitor or enrolled phone (decided in delivery, pending the maintainer).
  • A module whose name is a core lease sub-route (renew, exec) is refused at load, and POST /v1/leases/{id}/renew still renews (decided in delivery, pending the maintainer).
  • A console viewer sees why the picture stopped when a 0x07 frame arrives: 0x07 with a reason (each of the four shows its Shared values text, and the stream's error stays empty); an unknown tag is still skipped.

The tag pin in ui/src/data/stream.test.ts:625 (F13) covers the new tag in both tables.

Done when

  • pnpm test passes with the five tests above.
  • The OpenAPI document lists GET /v1/leases/{id}/stream with its statuses.
  • watching-the-screen.mdx and access-control.mdx say a lease holder can watch its own lease, and how a stream ends.
  • src/stream/AGENTS.md and src/http/AGENTS.md describe the lease route.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 26, 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 26, 2026
  3. self-assigned this
    on Oct 26, 2026
  4. added 2 commits that reference this issue on Oct 27, 2026
    569b0c4
    6316e10
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