Source code for bacommon.consolechannel
# Released under the MIT License. See LICENSE for details.
#
"""Payload types for the cloud-console SmartSocket channel kind.
One root pair per channel kind (see the hierarchy-per-contract rule
in ``streamcall-smartsocket.md``): commands go up from the console as
:class:`ConsoleCommand`, everything it displays comes back down as
:class:`ConsoleEvent`. The relay never decodes either -- a new event
type must never require a basn rollout.
Payloads here are pure data. In particular they carry log entries and
levels, not pre-colored html; rendering belongs to whoever displays
them. That is both the payload-model rule and one less hand-rolled
escaping site than the polling console this replaced.
**Client-visible**, because the app itself can hold the far end of a
console session. That makes these definitions a public wire contract
once a build that speaks them ships: storage names and type-id values
may never be repurposed, and removed ones stay retired. Add rather
than change.
"""
from __future__ import annotations # Docs-generation hack.
from typing import Annotated, override
from enum import Enum
from dataclasses import dataclass
from efro.logging import LogEntry
from efro.dataclassio import ioprepped, IOMultiType, IOAttrs
[docs]
class ConsoleCommandTypeID(Enum):
"""Type IDs for browser-to-adapter payloads."""
EXEC = 'e'
[docs]
class ConsoleCommand(IOMultiType[ConsoleCommandTypeID]):
"""Something the console's user asked us to do."""
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleCommandTypeID:
raise NotImplementedError()
[docs]
@override
@classmethod
def get_type(cls, type_id: ConsoleCommandTypeID) -> type[ConsoleCommand]:
out: type[ConsoleCommand]
if type_id is ConsoleCommandTypeID.EXEC:
out = ExecCommand
return out
raise ValueError(f'Unrecognized type-id {type_id}.')
[docs]
@ioprepped
@dataclass
class ExecCommand(ConsoleCommand):
"""Run some code on the target."""
code: str
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleCommandTypeID:
return ConsoleCommandTypeID.EXEC
[docs]
class ConsoleEventTypeID(Enum):
"""Type IDs for adapter-to-browser payloads."""
LOG_ENTRIES = 'l'
INSTANCE = 'i'
GAP = 'g'
STATUS = 's'
EXEC_ACK = 'e'
PERMISSION = 'p'
[docs]
class ConsoleEvent(IOMultiType[ConsoleEventTypeID]):
"""Something for the console to display."""
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleEventTypeID:
raise NotImplementedError()
[docs]
@override
@classmethod
def get_type(cls, type_id: ConsoleEventTypeID) -> type[ConsoleEvent]:
out: type[ConsoleEvent]
if type_id is ConsoleEventTypeID.LOG_ENTRIES:
out = LogEntriesEvent
elif type_id is ConsoleEventTypeID.INSTANCE:
out = InstanceEvent
elif type_id is ConsoleEventTypeID.GAP:
out = GapEvent
elif type_id is ConsoleEventTypeID.STATUS:
out = StatusEvent
elif type_id is ConsoleEventTypeID.EXEC_ACK:
out = ExecAckEvent
elif type_id is ConsoleEventTypeID.PERMISSION:
out = PermissionEvent
else:
raise ValueError(f'Unrecognized type-id {type_id}.')
return out
[docs]
@ioprepped
@dataclass
class LogEntriesEvent(ConsoleEvent):
"""A slice of the target's log."""
entries: list[LogEntry]
#: Where the target's log stands after these entries. The console
#: hands this back when it asks for a new handle, so a replacement
#: session resumes instead of replaying everything the target
#: still has cached.
next_index: int = 0
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleEventTypeID:
return ConsoleEventTypeID.LOG_ENTRIES
[docs]
@ioprepped
@dataclass
class InstanceEvent(ConsoleEvent):
"""Which app instance we're now talking to.
Sent on first contact and whenever the target's uuid changes,
which is how a restarted app announces itself. A change means the
log we were following is gone, so the console starts a fresh one.
"""
target_uuid: str
#: Display name of the device, when we know one.
device_name: str | None = None
#: True when this replaced an earlier instance rather than being
#: the first one we saw -- the console words those differently.
replaced_previous: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleEventTypeID:
return ConsoleEventTypeID.INSTANCE
[docs]
@ioprepped
@dataclass
class GapEvent(ConsoleEvent):
"""Log lines we can never show.
The target keeps a bounded archive, so entries can age out before
we ask for them. Derived from the archive's start index running
ahead of what we requested, not sent by the target.
"""
dropped: int
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleEventTypeID:
return ConsoleEventTypeID.GAP
[docs]
@ioprepped
@dataclass
class ExecAckEvent(ConsoleEvent):
"""We handed some code to the target.
Worth its own event because code with no output is otherwise
indistinguishable from code that never arrived.
"""
line_count: int
#: The target's own clock when it ran, so the console can line
#: the exec up against surrounding log timestamps.
target_time: float
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleEventTypeID:
return ConsoleEventTypeID.EXEC_ACK
[docs]
class ConsolePermission(Enum):
"""Where our permission to control the target stands.
A console session is offered to the target regardless; what this
tracks is whether the person at the device has agreed to let it
do anything. Until they have, no log flows and no code runs.
"""
#: Asked, and waiting on someone at the device to answer.
PENDING = 'p'
#: Allowed. Log flows and commands run.
GRANTED = 'g'
#: Refused, or nobody answered. Retrying immediately just puts
#: the same question in front of the same person, so a console
#: that sees this should stop asking rather than loop.
DENIED = 'd'
[docs]
@ioprepped
@dataclass
class PermissionEvent(ConsoleEvent):
"""Whether the target has agreed to be controlled.
Sent on every channel, including re-announcing the settled state
after a reconnect, so a console that joins late still knows why
it is (or isn't) seeing anything.
"""
state: ConsolePermission
#: Optional human-readable detail. English is fine; this surface
#: is admin-only.
message: str | None = None
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleEventTypeID:
return ConsoleEventTypeID.PERMISSION
[docs]
@ioprepped
@dataclass
class StatusEvent(ConsoleEvent):
"""A change in our ability to reach the target."""
contact: ConsoleContact
#: Optional human-readable detail. English is fine; this
#: surface is admin-only.
message: str | None = None
[docs]
@override
@classmethod
def get_type_id(cls) -> ConsoleEventTypeID:
return ConsoleEventTypeID.STATUS
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum