Skip to content

A lease holder streams its lease's screen #1015

Open

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; the console says why.

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

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).
  • 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 then ends; a device stop ends with no frame. If both come, the first to reach a slot decides; a slot already stopped writes nothing more.
  • 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

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.

  • 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.
  • 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; lease route and device route.
  • A console viewer sees why the picture stopped when a 0x07 frame arrives: 0x07 with a reason; 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 four 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    task:draftScope written; technical spec or its spec check missing.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions