The earlier specs claimed alknet-tty reuses alknet-call's FrameFramedReader/FrameFramedWriter 'framing utility' (ADR-052 §6, ADR-003 Am. 1). A pre-implementation sanity check found this was unsound: FrameFramedReader::read_frame() is hardcoded to deserialize EventEnvelope — the length-prefix read and the type-specific deserialize are one entangled call, not a separable utility. alknet-tty's negotiation payload is a NegotiateRequest, not an EventEnvelope, so the claimed reuse did not exist in a usable form. ADR-057: alknet-tty implements its own ~30-line length-prefixed framing directly on tokio AsyncRead/AsyncWrite. The format coincides with alknet-call's framing by convention (both are length-prefixed JSON); the implementations are independent. alknet-tty depends on alknet-core only — no alknet-call dependency. The ~30 lines of framing is an idiom, not a domain abstraction worth a cross-crate dependency. Updates: - ADR-003 Amendment 2: clarifies alknet-tty does not depend on alknet-call; the Am. 1 protocol-foundation exception remains for alknet-http/agent/napi (actual type reuse), not alknet-tty. - ADR-052 §6: framing is self-contained in alknet-tty; format coincides by convention, not by code reuse. - overview.md: dependency block drops alknet-call; new 'Why no alknet-call dependency' subsection explains the unsound-reuse finding. - tty-wire.md, tty-backend.md, tty README, crates/tty/README.md, top-level README: ADR tables, Design Decisions tables, and dependency edges updated to reflect alknet-core-only dependency. - ADR-053, ADR-054: References updated to ADR-003 Am. 2 + ADR-057.
206 lines
9.6 KiB
Markdown
206 lines
9.6 KiB
Markdown
# ADR-054: Local TTY Backend as a Sibling Crate (`alknet-tty-local`)
|
|
|
|
## Status
|
|
|
|
Accepted
|
|
|
|
## Context
|
|
|
|
The alknet-tty research (`docs/research/alknet-tty/phase-0-findings.md`
|
|
DP-1) posed the placement question for the local-process backend
|
|
(`std::process::Command` with piped stdio, or `portable_pty` for a real
|
|
PTY):
|
|
|
|
- **(a) In alknet-tty**: the crate ships with the local backend built-in.
|
|
Pro: zero-config runner, one crate gets a terminal/process-streaming
|
|
endpoint. Con: alknet-tty pulls in `portable_pty` even for deployments
|
|
that only use docker/ssh backends.
|
|
- **(b) In a sibling crate (`alknet-tty-local`)**: alknet-tty defines the
|
|
trait (ADR-053); the local backend is a separate crate. Pro:
|
|
alknet-tty stays dependency-light; consumers opt into the local
|
|
backend explicitly. Con: one extra crate for the common case.
|
|
|
|
The local backend is the simplest backend and the one that enables the
|
|
runner pattern (a process whose stdin/stdout/stderr/exit-code stream over
|
|
a framed bidi connection — the same shape as GitHub/Gitea Actions runners,
|
|
just over alknet's transport instead of HTTP polling). It has no heavy
|
|
dependencies in the pipe case — just `std` — but the PTY case pulls in
|
|
`portable_pty` (a non-trivial native dependency that builds on Unix
|
|
`openpty`/`ioctl` and Windows ConPTY).
|
|
|
|
The relevant constraint from ADR-053: alknet-tty itself depends only on
|
|
alknet-core and the wire-format codec (ADR-052). The `portable_pty`
|
|
dependency does not belong in the core tty crate — a docker-only
|
|
deployment or an SSH-PTY-only deployment should not pull in PTY allocation
|
|
code. This is the same inversion as `OperationAdapter` (ADR-017): the
|
|
trait lives where the types live; the implementations live where their
|
|
transport dependencies live.
|
|
|
|
### The feature-re-export compromise
|
|
|
|
The research recommended (b) sibling crate **behind a feature flag on
|
|
alknet-tty** for the common case (`features = ["local"]` → re-export from
|
|
`alknet-tty-local`). This keeps alknet-tty's default dependency surface
|
|
minimal while making the local backend a one-feature opt-in. The
|
|
`portable_pty` dependency lives in `alknet-tty-local`; alknet-tty itself
|
|
never depends on `portable_pty`.
|
|
|
|
## Decision
|
|
|
|
### 1. The local backend is a sibling crate, `alknet-tty-local`
|
|
|
|
`alknet-tty-local` implements `TtyBackend` for `LocalTtyBackend` (ADR-053),
|
|
backed by `portable_pty` for the PTY case and `std::process::Command` with
|
|
`Stdio::piped()` for the pipe/runner case. It depends on alknet-tty (for
|
|
the `TtyBackend`/`TtyHandle`/`TtyControl` traits) and on `portable_pty`
|
|
for the PTY case.
|
|
|
|
```
|
|
alknet-tty-local
|
|
├── alknet-tty (TtyBackend trait, TtyHandle, TtyControl, wire types)
|
|
├── alknet-core (Connection — via alknet-tty's re-export; not direct)
|
|
├── portable_pty (PTY allocation — the heavy dep, here not in alknet-tty)
|
|
└── libc (signal forwarding — REQ-TTY-02, Unix only)
|
|
```
|
|
|
|
### 2. `alknet-tty` re-exports the local backend behind a `local` feature
|
|
|
|
```toml
|
|
# alknet-tty Cargo.toml
|
|
[features]
|
|
default = []
|
|
local = ["dep:alknet-tty-local"] # re-export LocalTtyBackend
|
|
|
|
[dependencies]
|
|
alknet-tty-local = { path = "../alknet-tty-local", optional = true }
|
|
```
|
|
|
|
A consumer that wants the local backend enables `features = ["local"]`
|
|
on alknet-tty and gets `alknet_tty::local::LocalTtyBackend` re-exported
|
|
from `alknet-tty-local`. A consumer that only wants docker/ssh backends
|
|
uses the default features and depends on `alknet-tty-docker` /
|
|
`alknet-tty-ssh` (or their own backend crate) directly — no
|
|
`portable_pty` in the dependency tree.
|
|
|
|
This is the same feature-re-export pattern the Rust ecosystem uses for
|
|
optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in
|
|
`tokio-util`, `h2`, etc.). The seam is the `TtyBackend` trait; extraction
|
|
is cheap because the trait is the contract.
|
|
|
|
### 3. PTY vs pipe is a per-session choice, not a per-deployment choice
|
|
|
|
`TtyParams.terminal: Option<TerminalParams>` (ADR-053) selects the mode:
|
|
|
|
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY
|
|
via `portable_pty`. Terminal semantics: resize (via
|
|
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
|
|
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling
|
|
(the kernel PTY's line discipline). stdout and stderr are merged
|
|
(kernel PTY property — one output stream from the slave), so
|
|
`TtyHandle.stderr` is `None`.
|
|
- **`terminal: None`** — pipe mode, no PTY. `std::process::Command` with
|
|
`Stdio::piped()` for stdin/stdout/stderr. No resize, no
|
|
escape-sequence handling, but `kill(pid, sig)` still works for signal
|
|
forwarding. stdout and stderr are separate streams, so
|
|
`TtyHandle.stderr` is `Some`. This is the **runner** case — a
|
|
command-streaming endpoint with no terminal semantics.
|
|
|
|
The same `LocalTtyBackend` serves both; the `allocate()` call branches on
|
|
`params.terminal`. A deployment that only does terminals always sends
|
|
`Some`; a deployment that only does runners always sends `None`; a
|
|
deployment that does both (a hub that runs agents in PTYs and runs
|
|
`cargo test` as a runner) sends the appropriate one per session.
|
|
|
|
### 4. The runner pattern is preserved, not specialized
|
|
|
|
The pipe mode (`terminal: None`) is the "runner" generalization the
|
|
research identified: a process whose stdin/stdout/stderr/exit-code stream
|
|
over a framed bidi connection. This is functionally identical to
|
|
GitHub/Gitea Actions runners, just over alknet's transport instead of
|
|
HTTP polling:
|
|
|
|
- A coordinator sends a negotiation frame with
|
|
`{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }`.
|
|
- The endpoint runs `cargo test` with piped stdio, streams stdout/stderr
|
|
chunks back, sends `{"type":"exit","code":N}` when it finishes (ADR-055).
|
|
- The coordinator gets reliable completion notification (the exit
|
|
control chunk + stream close) — no polling.
|
|
|
|
The runner-specific API surface (job management, log persistence, task
|
|
graph integration) is **out of scope for alknet-tty**. alknet-tty provides
|
|
the *mechanism* (a framed byte stream for a process); the runner *policy*
|
|
is a downstream crate's job. This ADR commits to preserving the option
|
|
(`terminal: None` → pipe mode) and not building runner policy into
|
|
alknet-tty. See OQ-46.
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
|
|
- alknet-tty's default dependency surface is minimal (alknet-core + the
|
|
wire-format codec). A docker-only or ssh-only deployment never pulls
|
|
in `portable_pty`.
|
|
- The local backend is a one-feature opt-in (`features = ["local"]`) for
|
|
the common case — a consumer that wants a terminal/runner endpoint with
|
|
no docker or SSH gets it with one feature flag, not a separate
|
|
dependency.
|
|
- PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals
|
|
and runners. A hub that does both doesn't need two backends.
|
|
- The runner pattern is preserved without baking runner policy into
|
|
alknet-tty. The mechanism is the framed byte stream; the policy is
|
|
downstream.
|
|
- The sibling-crate placement composes with ADR-003's no-handler-depends-
|
|
on-another-handler rule: alknet-tty-local depends on alknet-tty (for
|
|
the trait), alknet-tty does not depend on alknet-tty-local (the
|
|
feature re-export is optional).
|
|
|
|
**Negative:**
|
|
|
|
- A consumer that wants the local backend must enable a feature flag
|
|
(`features = ["local"]`). Forgetting the flag results in the
|
|
`alknet_tty::local` module not existing — a compile error, not a silent
|
|
miss. This is the standard Rust feature-flag trade and is
|
|
self-documenting.
|
|
- One extra crate in the workspace (`alknet-tty-local` alongside
|
|
`alknet-tty`). The workspace `Cargo.toml` gains a member; version
|
|
coordination is per-crate. This is the established pattern (alknet-http
|
|
is one crate with colocated server + adapters; alknet-call is separate
|
|
from alknet-core).
|
|
- The runner-specific API surface (job management, log persistence) is
|
|
not in alknet-tty. A downstream crate that wants a full runner builds
|
|
on the pipe mode + the wire format. This is the right layering
|
|
(mechanism vs policy) but means a "runner crate" is a separate future
|
|
deliverable, not part of alknet-tty. See OQ-46.
|
|
|
|
## Door type
|
|
|
|
**Two-way.** The sibling-crate placement is reversible: if the local
|
|
backend turned out to be the only backend anyone used, merging
|
|
`alknet-tty-local` back into `alknet-tty` (behind the same `local`
|
|
feature, just in the same crate) is mechanical — the trait is the seam,
|
|
and the feature gate already exists. The cost of reversal is low
|
|
(re-exports become local modules), and no downstream consumer breaks (the
|
|
`alknet_tty::local::LocalTtyBackend` path stays valid).
|
|
|
|
This is a two-way door that is **decided** (sibling crate + feature
|
|
re-export), not deferred. The decision is made now; the reversal is cheap
|
|
if a future consolidation warrants it. See ADR-009 §"What this framework
|
|
is NOT" — door type classifies reversal cost, not urgency.
|
|
|
|
## References
|
|
|
|
- `docs/research/alknet-tty/phase-0-findings.md` DP-1 — the placement
|
|
question this ADR resolves
|
|
- [ADR-003](003-crate-decomposition.md) + Amendments 1 & 2 — crate
|
|
decomposition rule (the sibling-crate placement preserves it)
|
|
- [ADR-009](009-one-way-door-decision-framework.md) — door-type-as-deferral
|
|
anti-pattern (this ADR's two-way-door classification is reversal cost,
|
|
not a deferral)
|
|
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
|
|
adapter-location-map pattern (trait where types live, implementation
|
|
where deps live) this ADR follows
|
|
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
|
|
trait this crate implements
|
|
- OQ-46 — runner API surface (deferred(scope): mechanism in alknet-tty,
|
|
policy is a downstream crate)
|
|
- Spec: [crates/tty/tty-local.md](../crates/tty/tty-local.md) |