livebook/lib/livebook/runtime.ex
2023-09-26 05:38:15 +02:00

1027 lines
30 KiB
Elixir

defprotocol Livebook.Runtime do
# This protocol defines an interface for code evaluation backends.
#
# Usually a runtime involves a set of processes responsible for
# evaluation, which could be running on a different node, however
# the protocol does not require that.
#
# ## Files
#
# The runtime can request access to notebook files by sending a
# request:
#
# * `{:runtime_file_entry_path_request, reply_to, name}`
#
# to which the runtime owner is supposed to reply with
# `{:runtime_file_entry_path_reply, reply}` where `reply` is either
# `{:ok, path}` or `{:error, message | :forbidden}` if accessing the
# file fails. Note that `path` should be accessible within the runtime
# and can be obtained using `transfer_file/4`.
#
# Similarly the runtime can request details about the file source:
#
# * `{:runtime_file_entry_spec_request, reply_to, name}`
#
# Instead of a path, the owner replies with a details map.
#
# ## Apps
#
# The runtime may be used to run Livebook apps and can request app
# information by sending a request:
#
# * `{:runtime_app_info_request, reply_to}`
#
# The owner replies with `{:runtime_app_info_reply, info}`, where
# info is a details map.
@typedoc """
An arbitrary term identifying an evaluation container.
A container is an abstraction of an isolated group of evaluations.
Containers are mostly independent and therefore can be evaluated
concurrently (if possible).
Note that every evaluation can use the resulting binding and env
of any previous evaluation, even from a different container.
"""
@type container_ref :: term()
@typedoc """
An arbitrary term identifying an evaluation.
"""
@type evaluation_ref :: term()
@typedoc """
A pair identifying evaluation together with its container.
"""
@type locator :: {container_ref(), evaluation_ref()}
@typedoc """
A sequence of locators representing a multi-stage evaluation.
The evaluation locators should be ordered from most recent to oldest.
"""
@type parent_locators :: list(locator())
@typedoc """
An output emitted during evaluation or as the final result.
"""
@type output ::
ignored_output()
| terminal_text_output()
| plain_text_output()
| markdown_output()
| image_output()
| js_output()
| frame_output()
| frame_update_output()
| tabs_output()
| grid_output()
| input_output()
| control_output()
| error_output()
@typedoc """
An empty output that should be ignored whenever encountered.
"""
@type ignored_output :: %{type: :ignored}
@typedoc ~S"""
Terminal text content.
Supports ANSI escape codes and overwriting lines with `\r`.
Adjacent outputs with `:chunk` set to `true` are merged and rendered
as a whole.
"""
@type terminal_text_output :: %{type: :terminal_text, text: String.t(), chunk: boolean()}
@typedoc """
Plain text content.
Adjacent outputs with `:chunk` set to `true` are merged and rendered
as a whole.
Similar to `t:markdown/0`, but with no special markup.
"""
@type plain_text_output :: %{type: :plain_text, text: String.t(), chunk: boolean()}
@typedoc """
Markdown content.
Adjacent outputs with `:chunk` set to `true` are merged and rendered
as a whole.
"""
@type markdown_output :: %{type: :markdown, text: String.t(), chunk: boolean()}
@typedoc """
A raw image in the given format.
## Pixel data
Note that a special `image/x-pixel` MIME type is supported. The
binary consists of the following consecutive parts:
* height - 32 bits (unsigned big-endian integer)
* width - 32 bits (unsigned big-endian integer)
* channels - 8 bits (unsigned integer)
* data - pixel data in HWC order
Pixel data consists of 8-bit unsigned integers. The number of channels
can be either: 1 (grayscale), 2 (grayscale + alpha), 3 (RGB), or 4
(RGB + alpha).
"""
@type image_output :: %{type: :image, content: binary(), mime_type: String.t()}
@typedoc """
JavaScript powered output with dynamic data and events.
See `Kino.JS` and `Kino.JS.Live` for more details.
## Export
The `:export` specifies whether the given output supports persistence.
When enabled, the JS view server should handle the following message:
{:export, pid(), info :: %{ref: ref()}}
And reply with:
{:export_reply, export_result, info :: %{ref: ref()}}
Where `export_result` is a tuple `{info_string, payload}`.
`info_string` is used as the info string for the Markdown code block,
while `payload` is its content. Payload can be either a string,
otherwise it is serialized into JSON.
"""
@type js_output() :: %{
type: :js,
js_view: js_view(),
export: boolean()
}
@typedoc """
A JavaScript view definition.
JS view is a component rendered on the client side and possibly
interacting with a server process within the runtime.
* `:ref` - unique identifier
* `:pid` - the server process holding the data and handling
interactions
## Assets
The `:assets` map includes information about the relevant files.
* `:archive_path` - an absolute path to a `.tar.gz` archive with
all the assets
* `:hash` - a checksum of all assets in the archive
* `:js_path` - a relative asset path pointing to the JavaScript
entrypoint module
* `:cdn_url` - an absolute URL to a CDN directory where the asset
files can be accessed from. Note that this URL is not guaranteed
to be accessible, since it may be pointing to a private package
## Communication protocol
A client process should connect to the server process by sending:
{:connect, pid(), info :: %{ref: ref(), origin: term()}}
And expect the following reply:
{:connect_reply, payload, info :: %{ref: ref()}}
The server process may then keep sending one of the following events:
{:event, event :: String.t(), payload, info :: %{ref: ref()}}
The client process may keep sending one of the following events:
{:event, event :: String.t(), payload, info :: %{ref: ref(), origin: term()}}
The client can also send a ping message:
{:ping, pid(), metadata :: term(), info :: %{ref: ref()}}
And the server should respond with:
{:pong, metadata :: term(), info :: %{ref: ref()}}
"""
@type js_view :: %{
ref: ref(),
pid: Process.dest(),
assets: %{
archive_path: String.t(),
hash: String.t(),
js_path: String.t(),
cdn_url: String.t() | nil
}
}
@typedoc """
An area to dynamically put outputs into.
This output includes the initial outputs and can be later updated
by sending `t:frame_update_output/0` outputs.
The outputs order is always reversed, that is, most recent outputs
are at the top of the stack.
"""
@type frame_output :: %{
type: :frame,
ref: frame_ref(),
outputs: list(output()),
placeholder: boolean()
}
@typedoc """
An output emitted to update and existing `t:frame_output/0`.
"""
@type frame_update_output :: %{
type: :frame_update,
ref: frame_ref(),
update: {:replace, list(output())} | {:append, list(output())}
}
@type frame_ref :: String.t()
@typedoc """
Multiple outputs arranged into tabs.
"""
@type tabs_output :: %{type: :tabs, outputs: list(t()), labels: list(String.t())}
@typedoc """
Multiple outputs arranged in a grid.
"""
@type grid_output :: %{
type: :grid,
outputs: list(t()),
columns: pos_integer(),
gap: non_neg_integer(),
boxed: boolean()
}
@typedoc """
An input field.
* `:ref` - a unique identifier
* `:id` - a persistent input identifier, the same on every
reevaluation
* `:default` - the initial input value
* `:destination` - the process to send event messages to
* `:attrs` - input-specific attributes. The required fields are
`:type` and `:default`
"""
@type input_output :: %{
type: :input,
ref: ref(),
id: input_id(),
destination: Process.dest(),
attrs: input_attrs()
}
@type input_id :: String.t()
@type input_attrs ::
%{
type: :text,
default: String.t(),
label: String.t(),
debounce: :blur | non_neg_integer()
}
| %{
type: :textarea,
default: String.t(),
label: String.t(),
debounce: :blur | non_neg_integer(),
monospace: boolean()
}
| %{
type: :password,
default: String.t(),
label: String.t(),
debounce: :blur | non_neg_integer()
}
| %{
type: :number,
default: number() | nil,
label: String.t(),
debounce: :blur | non_neg_integer()
}
| %{
type: :url,
default: String.t() | nil,
label: String.t(),
debounce: :blur | non_neg_integer()
}
| %{
type: :select,
default: term(),
label: String.t(),
options: list({value :: term(), label :: String.t()})
}
| %{
type: :checkbox,
default: boolean(),
label: String.t()
}
| %{
type: :range,
default: number(),
label: String.t(),
debounce: :blur | non_neg_integer(),
min: number(),
max: number(),
step: number()
}
| %{
type: :utc_datetime,
default: NaiveDateTime.t() | nil,
label: String.t(),
min: NaiveDateTime.t() | nil,
max: NaiveDateTime.t() | nil
}
| %{
type: :utc_time,
default: Time.t() | nil,
label: String.t(),
min: Time.t() | nil,
max: Time.t() | nil
}
| %{
type: :date,
default: Date.t(),
label: String.t(),
min: Date.t(),
max: Date.t()
}
| %{
type: :color,
default: String.t(),
label: String.t(),
debounce: :blur | non_neg_integer()
}
| %{
type: :image,
default: nil,
label: String.t(),
format: :rgb | :png | :jpeg,
size: {pos_integer(), pos_integer()} | nil,
fit: :match | :contain | :pad | :crop
}
| %{
type: :audio,
default: nil,
label: String.t(),
format: :pcm_f32 | :wav,
sampling_rate: pos_integer()
}
| %{
type: :file,
default: nil,
label: String.t(),
accept: list(String.t()) | :any
}
@typedoc """
A control widget.
* `:ref` - a unique identifier
* `:destination` - the process to send event messages to
* `:attrs` - control-specific attributes. The only required field
is `:type`
## Events
All control events are sent to `:destination` as `{:event, id, info}`,
where info is a map including additional details. In particular, it
always includes `:origin`, which is an opaque identifier of the client
that triggered the event.
"""
@type control_output :: %{
type: :control,
ref: ref(),
destination: Process.dest(),
attrs: control_attrs()
}
@type control_attrs ::
%{
type: :keyboard,
events: list(:keyup | :keydown | :status),
default_handlers: :off | :on | :disable_only
}
| %{
type: :button,
label: String.t()
}
| %{
type: :form,
fields: list({field :: atom(), input_output()}),
submit: String.t() | nil,
# Currently we always use true, but we can support
# other tracking modes in the future
report_changes: %{(field :: atom()) => true},
reset_on_submit: list(field :: atom())
}
@type ref :: String.t()
@typedoc """
Error content, usually representing evaluation failure.
The `:context` field is used by Livebook to suggest a way to fix
the error.
"""
@type error_output :: %{
type: :error,
message: String.t(),
context:
{:missing_secret, name :: String.t()}
| {:interrupt, variant :: :normal | :error, message :: String.t()}
| {:file_entry_forbidden, name :: String.t()}
| nil
}
@typedoc """
Additional information about a completed evaluation.
## Identifiers
When possible, the metadata may include a list of identifiers (such
as variables, modules, imports) used during evaluation, and a list
of identifiers defined along with the version (such as a hash digest
of the underlying value). With this information, Livebook can track
dependencies between evaluations and avoids unnecessary reevaluations.
"""
@type evaluation_response_metadata :: %{
errored: boolean(),
evaluation_time_ms: non_neg_integer(),
code_markers: list(code_marker()),
memory_usage: runtime_memory(),
identifiers_used: list(identifier :: term()) | :unknown,
identifiers_defined: %{(identifier :: term()) => version :: term()}
}
@typedoc """
Includes information about a running or finished doctest.
Failed doctests have additional details formatted as a string.
"""
@type doctest_report ::
%{
status: :running | :success,
line: pos_integer()
}
| %{
status: :failed,
column: pos_integer(),
line: pos_integer(),
end_line: pos_integer(),
details: String.t()
}
@typedoc """
Recognised intellisense request.
"""
@type intellisense_request ::
completion_request()
| details_request()
| signature_request()
| format_request()
@typedoc """
Expected intellisense response.
Responding with `nil` indicates there is no relevant reply and
effectively aborts the request, so it's suitable for error cases.
"""
@type intellisense_response ::
nil
| completion_response()
| details_response()
| signature_response()
| format_response()
@typedoc """
Looks up a list of identifiers that are suitable code completions
for the given hint.
"""
@type completion_request :: {:completion, hint :: String.t()}
@type completion_response :: %{
items: list(completion_item())
}
@type completion_item :: %{
label: String.t(),
kind: completion_item_kind(),
detail: String.t() | nil,
documentation: String.t() | nil,
insert_text: String.t()
}
@type completion_item_kind ::
:function | :module | :struct | :interface | :type | :variable | :field | :keyword
@typedoc """
Looks up more details about an identifier found in `column` in
`line`.
"""
@type details_request :: {:details, line :: String.t(), column :: pos_integer()}
@type details_response :: %{
range: %{
from: non_neg_integer(),
to: non_neg_integer()
},
contents: list(String.t())
}
@typedoc """
Looks up a list of function signatures matching the given hint.
The resulting information includes current position in the argument
list.
"""
@type signature_request :: {:signature, hint :: String.t()}
@type signature_response :: %{
active_argument: non_neg_integer(),
signature_items: list(signature_item())
}
@type signature_item :: %{
signature: String.t(),
arguments: list(String.t()),
documentation: String.t() | nil
}
@typedoc """
Formats the given code snippet.
"""
@type format_request :: {:format, code :: String.t()}
@type format_response :: %{
code: String.t() | nil,
code_markers: list(code_marker())
}
@typedoc """
A descriptive error or warning pointing to a specific line in the code.
"""
@type code_marker :: %{
line: pos_integer(),
description: String.t(),
severity: :error | :warning
}
@typedoc """
A detailed runtime memory usage.
The runtime may periodically send memory usage updates as
* `{:runtime_memory_usage, runtime_memory()}`
"""
@type runtime_memory :: %{
atom: size_in_bytes(),
binary: size_in_bytes(),
code: size_in_bytes(),
ets: size_in_bytes(),
other: size_in_bytes(),
processes: size_in_bytes(),
total: size_in_bytes()
}
@type size_in_bytes :: non_neg_integer()
@typedoc """
An information about a smart cell kind.
The `kind` attribute is an opaque identifier.
Whenever new smart cells become available the runtime should send
the updated list as
* `{:runtime_smart_cell_definitions, list(smart_cell_definition())}`
Additionally, the runtime may report extra definitions that require
installing external packages, as described by `:requirement_presets`.
Also see `add_dependencies/3`.
"""
@type smart_cell_definition :: %{
kind: String.t(),
name: String.t(),
requirement_presets:
list(%{
name: String.t(),
packages: list(package())
})
}
@type package :: %{name: String.t(), dependency: dependency()}
@type dependency :: term()
@type search_packages_response :: {:ok, list(package_details())} | {:error, String.t()}
@type package_details :: %{
name: String.t(),
version: String.t(),
description: String.t() | nil,
url: String.t() | nil,
dependency: dependency()
}
@typedoc """
An information about a predefined code snippets.
"""
@type snippet_definition :: example_snippet_definition() | file_action_snippet_definition()
@typedoc """
Code snippet with fixed source, serving as an example or boilerplate.
"""
@type example_snippet_definition :: %{
type: :example,
name: String.t(),
icon: String.t(),
variants:
list(%{
name: String.t(),
source: String.t(),
packages: list(package())
})
}
@typedoc """
Code snippet for acting on files of the given type.
The action is applicable to files matching any of the specified types,
where a type can be either:
* specific MIME type, like `text/csv`
* MIME type family, like `image/*`
* file extension, like `.csv`
The source is expected to include `{{NAME}}`, which is replaced with
the actual file name.
"""
@type file_action_snippet_definition :: %{
type: :file_action,
file_types: :any | list(String.t()),
description: String.t(),
source: String.t(),
packages: list(package())
}
@type smart_cell_ref :: String.t()
@type smart_cell_attrs :: map()
@typedoc """
Marks a part of smart cell source.
Both the offset ans size are expressed in bytes.
"""
@type chunk :: {offset :: non_neg_integer(), size :: non_neg_integer()}
@type chunks :: list(chunks())
@typedoc """
Smart cell editor configuration.
"""
@type editor :: %{language: String.t() | nil, placement: :bottom | :top, source: String.t()}
@typedoc """
An opaque file reference.
Such reference can be obtained from a file input, for example.
The runtime may ask for the file by sending a request:
* `{:runtime_file_path_request, reply_to, file_ref}`
to which the runtime owner is supposed to reply with
`{:runtime_file_path_reply, reply}` where `reply` is either
`{:ok, path}` or `:error` if no matching file can be found. Note
that `path` should be accessible within the runtime and can be
obtained using `transfer_file/4`.
"""
@type file_ref :: {:file, id :: String.t()}
@doc """
Returns relevant information about the runtime.
Every runtime is expected to have an item with the `"Type"` label.
"""
@spec describe(t()) :: list({label :: String.t(), String.t()})
def describe(runtime)
@doc """
Synchronously initializes the given runtime.
This function starts the necessary resources and processes.
"""
@spec connect(t()) :: {:ok, t()} | {:error, String.t()}
def connect(runtime)
@doc """
Checks if the given runtime is in a connected state.
"""
@spec connected?(t()) :: boolean()
def connected?(runtime)
@doc """
Sets the caller as the runtime owner.
The runtime owner is the target for most of the runtime messages
and the runtime lifetime is tied to the owner.
It is advised for each runtime to have a leading process that is
coupled to the lifetime of the underlying runtime resources. In
such case the `take_ownership/2` function may start monitoring this
process and return the monitor reference. This way the owner is
notified when the runtime goes down by listening to the :DOWN
message with that reference.
## Options
* `:runtime_broadcast_to` - the process to send runtime broadcast
events to. Defaults to the owner
"""
@spec take_ownership(t(), keyword()) :: reference()
def take_ownership(runtime, opts \\ [])
@doc """
Synchronously disconnects the runtime and cleans up the underlying
resources.
"""
@spec disconnect(t()) :: {:ok, t()}
def disconnect(runtime)
@doc """
Returns a fresh runtime of the same type with the same configuration.
Note that the runtime is in a stopped state.
"""
@spec duplicate(Runtime.t()) :: Runtime.t()
def duplicate(runtime)
@doc """
Asynchronously parses and evaluates the given code.
The given `locator` identifies the container where the code should
be evaluated as well as the evaluation reference to store the
resulting context under.
Additionally, `parent_locators` points to a sequence of previous
evaluations to be used as the starting point of this evaluation.
## Communication
During evaluation a number of messages may be sent to the runtime
owner. All captured outputs have the form:
* `{:runtime_evaluation_output, evaluation_ref, output}`
When the evaluation completes, the resulting output and metadata
is sent as:
* `{:runtime_evaluation_response, evaluation_ref, output, metadata}`
Outputs may include input fields. The evaluation may then request
the current value of a previously rendered input by sending
* `{:runtime_evaluation_input_request, evaluation_ref, reply_to, input_id}`
to the runtime owner who is supposed to reply with
`{:runtime_evaluation_input_reply, reply}` where `reply` is either
`{:ok, value}` or `:error` if no matching input can be found.
If the evaluation state within a container is lost (for example when
a process goes down), the runtime may send
* `{:runtime_container_down, container_ref, message}`
to notify the owner.
### Doctests
If the cell includes doctests, the runtime can evaluate them and
send reports as a message:
* `{:runtime_doctest_report, evaluation_ref, doctest_report}`
## Options
* `:file` - the file considered as the source during evaluation.
This information is relevant for errors formatting and imparts
the value of `__DIR__`
* `:smart_cell_ref` - a reference of the smart cell which code is
to be evaluated, if applicable
"""
@spec evaluate_code(t(), atom(), String.t(), locator(), parent_locators(), keyword()) :: :ok
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ [])
@doc """
Disposes of an evaluation identified by the given locator.
This can be used to cleanup resources related to an old evaluation
if it is no longer needed.
"""
@spec forget_evaluation(t(), locator()) :: :ok
def forget_evaluation(runtime, locator)
@doc """
Disposes of an evaluation container identified by the given ref.
This should be used to cleanup resources keeping track of the
container all of its evaluations.
"""
@spec drop_container(t(), container_ref()) :: :ok
def drop_container(runtime, container_ref)
@doc """
Asynchronously handles an intellisense request.
This part of runtime functionality is used to provide language-
and context-specific intellisense features in the text editor.
The response is sent to the `send_to` process as
* `{:runtime_intellisense_response, ref, request, response}`.
The given `parent_locators` identifies a sequence of evaluations
that may be used as the context when resolving the request (if relevant).
"""
@spec handle_intellisense(t(), pid(), intellisense_request(), parent_locators()) :: reference()
def handle_intellisense(runtime, send_to, request, parent_locators)
@doc """
Reads file at the given absolute path within the runtime file system.
"""
@spec read_file(Runtime.t(), String.t()) :: {:ok, binary()} | {:error, String.t()}
def read_file(runtime, path)
@doc """
Transfers file at `path` to the runtime.
This operation is asynchronous and `callback` is called with the
path of the transferred file (on the runtime host) once the transfer
is complete.
If the runtime is on the same host as the caller, the implementation
may simply use `path`.
"""
@spec transfer_file(t(), String.t(), String.t(), (path :: String.t() | nil -> any())) :: :ok
def transfer_file(runtime, path, file_id, callback)
@doc """
Updates the id by which the file is referenced.
"""
@spec relabel_file(t(), String.t(), String.t()) :: :ok
def relabel_file(runtime, file_id, new_file_id)
@doc """
Cleans up resources allocated with `transfer_file/4`, if any.
"""
@spec revoke_file(t(), String.t()) :: :ok
def revoke_file(runtime, file_id)
@doc """
Starts a smart cell of the given kind.
`kind` must point to an available `t:smart_cell_definition/0`, which
was reported by the runtime. The cell gets initialized with `attrs`,
which represent the persisted cell state and determine the current
version of the generated source code. The given `ref` is used to
identify the cell.
The cell may depend on evaluation context to provide a better user
experience, for instance it may suggest relevant variable names.
Similarly to `evaluate_code/5`, `parent_locators` must be specified
pointing to the sequence of evaluations to use as the context. When
the sequence changes, it can be updated with `set_smart_cell_parent_locators/3`.
Once the cell starts, the runtime sends the following message
* `{:runtime_smart_cell_started, ref, %{js_view: js_view(), source: String.t(), chunks: chunks() | nil, editor: editor() | nil}}`
In case of an unexpected failure it should also send
* `{:runtime_smart_cell_down, ref}`
## Communication
Apart from the regular JS view communication, the cell sends updates
to the runtime owner whenever attrs and the generated source code
change.
* `{:runtime_smart_cell_update, ref, attrs, source, %{reevaluate: boolean(), chunks: chunks() | nil}}`
The attrs are persisted and may be used to restore the smart cell
state later. Note that for persistence they get serialized and
deserialized as JSON.
"""
@spec start_smart_cell(
t(),
String.t(),
smart_cell_ref(),
smart_cell_attrs(),
parent_locators()
) :: :ok
def start_smart_cell(runtime, kind, ref, attrs, parent_locators)
@doc """
Updates the parent locator used by a smart cell as its context.
See `start_smart_cell/5` for more details.
"""
@spec set_smart_cell_parent_locators(t(), smart_cell_ref(), parent_locators()) :: :ok
def set_smart_cell_parent_locators(runtime, ref, parent_locators)
@doc """
Stops smart cell identified by the given reference.
"""
@spec stop_smart_cell(t(), smart_cell_ref()) :: :ok
def stop_smart_cell(runtime, ref)
@doc """
Returns true if the given runtime by definition has only a specific
set of dependencies.
Note that if restarting the runtime allows for installing different
dependencies, the dependencies are not considered fixed.
When dependencies are fixed, the following functions are allowed to
raise an implementation error: `add_dependencies/3`, `search_packages/3`.
"""
@spec fixed_dependencies?(t()) :: boolean()
def fixed_dependencies?(runtime)
@doc """
Updates the given source code to install the given dependencies.
"""
@spec add_dependencies(t(), String.t(), list(dependency())) ::
{:ok, String.t()} | {:error, String.t()}
def add_dependencies(runtime, code, dependencies)
@doc """
Checks if the given dependencies are installed within the runtime.
"""
@spec has_dependencies?(t(), list(dependency())) :: boolean()
def has_dependencies?(runtime, dependencies)
@doc """
Returns a list of predefined code snippets.
"""
@spec snippet_definitions(t()) :: list(snippet_definition())
def snippet_definitions(runtime)
@doc """
Looks up packages matching the given search.
The response is sent to the `send_to` process as
* `{:runtime_search_packages_response, ref, response}`.
"""
@spec search_packages(t(), pid(), String.t()) :: reference()
def search_packages(runtime, send_to, search)
@doc """
Disables dependencies cache, so they are fetched and compiled from
scratch.
"""
@spec disable_dependencies_cache(t()) :: :ok
def disable_dependencies_cache(runtime)
@doc """
Sets the given environment variables.
"""
@spec put_system_envs(t(), list({String.t(), String.t()})) :: :ok
def put_system_envs(runtime, envs)
@doc """
Unsets the given environment variables.
"""
@spec delete_system_envs(t(), list(String.t())) :: :ok
def delete_system_envs(runtime, names)
end