phase 4: architecture docs + BAST schema + renumbered ADRs
Port the alknet-tty architecture docs into alktty and add the BAST document for the alk/tty wire format. Docs-only; no Rust source changes. Spec docs (docs/architecture/, flat layout — single-crate repo): - overview.md — crate purpose, two-carriage model, deps, ALPN, backend location map, feature gates - tty-wire.md — 5-byte chunk codec, control channel split (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels - tty-backend.md — TtyBackend trait, TtyHandle, TtyControl, REQ-TTY-01 (backends need not be natively async) - tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk ordering (ADR-004), cancel cleanup (ADR-005), access control - tty-local.md — LocalTtyBackend (local feature module), PTY + pipe modes, REQ-TTY-02 (signal forwarding to process group) - README.md — architecture index ADRs (docs/architecture/decisions/, renumbered 001..008 from alknet 052,053,054,055,056,057,077,093 in order): - 001 wire format + two-carriage model (incl. Phase 7 control- channel split amendment) - 002 TtyBackend trait + TtyHandle - 003 local backend placement (records both the alknet sibling- crate decision and the alktty single-crate consolidation behind a local feature) - 004 exit code on a control chunk - 005 backend cleanup on session cancel - 006 self-contained negotiation framing - 007 tty inside channels (reversed by 008; kept for historical context with reversal notice) - 008 channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) BAST document (docs/architecture/tty-bast.md): - Normative JSON spec for the alk/tty wire format, conforming to the BAST meta-schema at https://alk.dev/bast/v1/schema - 5-byte chunk header (struct, big-endian: stream_type uint8, length uint32) + StreamType enum (Stdin=0..CtrlOut=4) - ControlMessage union (field-name discriminator on type: resize/signal/eof/exit) with documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding - NegotiationFrame (4-byte BE length + UTF-8 JSON body) + NegotiateRequest / TerminalParams JSON shapes - StreamType enum deviation noted: on-wire uint8, not BAST's standard u32 enum index (chunk header is 5 bytes, not 8) - alktty does not depend on alktype; the hand-rolled wire.rs is the runtime codec, the BAST is the human-readable contract AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8 mapping (the previous table substituted ADR-050 for 054, relabeled 056 as control-message split, dropped 077, and added a new control-split ADR at 006 — inconsistent with both the plan and the prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet- core ADR, not tty-specific, and is not ported; the Phase 7 control split stays as an amendment inside ADR-001, mirroring alknet. Verification (all pass, no Rust source changed): - cargo test (80 passed) - cargo test --all-features (103 passed) - cargo clippy --all-targets -- -D warnings (clean) - cargo fmt --check (clean) - cargo check --target wasm32-unknown-unknown (clean) - cargo clippy --target wasm32-unknown-unknown -- -D warnings (clean) - cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in src/session.rs and src/channels.rs (untouched by this commit; not introduced here) - BAST JSON parses; StreamType indices match wire.rs constants (0=Stdin..4=CtrlOut) - all markdown cross-reference links resolve
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
# ADR-003: Local TTY Backend Placement (Single Crate with `local` Feature)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate
|
||||
consolidation recorded as the alktty resolution. Cross-references
|
||||
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
|
||||
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
|
||||
ADR-093→008. Alknet ADRs referenced by alknet number (003, 009, 017)
|
||||
are not ported into alktty's ADR range because they are not tty-specific;
|
||||
the alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
|
||||
authoritative.)
|
||||
|
||||
## Context
|
||||
|
||||
The alknet-tty research (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 alktty**: the crate ships with the local backend built-in.
|
||||
Pro: zero-config runner, one crate gets a terminal/process-streaming
|
||||
endpoint. Con: alktty pulls in `portable_pty` even for deployments
|
||||
that only use docker/ssh backends.
|
||||
- **(b) In a sibling crate (`alknet-tty-local`)**: alktty defines the
|
||||
trait (ADR-002); the local backend is a separate crate. Pro: alktty
|
||||
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 alk'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-002: alktty itself depends only on
|
||||
alkcall and the wire-format codec (ADR-001). 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`
|
||||
(alknet ADR-017): the trait lives where the types live; the
|
||||
implementations live where their transport dependencies live.
|
||||
|
||||
### The alknet mono-repo decision (sibling crate + feature re-export)
|
||||
|
||||
In the alknet mono-repo, 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`.
|
||||
|
||||
The sibling-crate placement was motivated by a cyclic-dependency
|
||||
workaround: `alknet-tty-local` depends on `alknet-tty` for the trait,
|
||||
and alknet ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend`
|
||||
behind a `local` feature — which cargo rejects (a crate cannot re-export
|
||||
from a sibling crate it depends on via an optional dep AND have that
|
||||
sibling depend back on it). The assembly-layer workaround (consumer
|
||||
depends on both crates directly) worked but was awkward.
|
||||
|
||||
### The alktty consolidation (single crate, `local` feature)
|
||||
|
||||
alktty is a standalone single crate (not a mono-repo). The cyclic-dep
|
||||
workaround that motivated the alknet sibling-crate decision does not
|
||||
apply: the local backend can live in the same crate as the trait, gated
|
||||
behind a `local` cargo feature. The feature gate still keeps
|
||||
`portable_pty` out of the default dependency tree (a docker-only or
|
||||
ssh-only deployment enables neither feature and never pulls in
|
||||
`portable_pty`); the only thing that changes is the crate boundary.
|
||||
|
||||
This ADR records both decisions: the original alknet sibling-crate
|
||||
decision (preserved as the historical context for why the local backend
|
||||
is a separable concern), and the alktty consolidation (the operative
|
||||
decision — the local backend is folded into alktty behind a `local`
|
||||
feature).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The local backend is folded into alktty behind a `local` feature
|
||||
|
||||
`LocalTtyBackend` lives in `src/local/` (a feature-gated module),
|
||||
implements `TtyBackend` (ADR-002), and is re-exported as
|
||||
`alktty::local::LocalTtyBackend`. The `local` cargo feature pulls in
|
||||
the heavy deps:
|
||||
|
||||
```toml
|
||||
# alktty Cargo.toml
|
||||
[features]
|
||||
default = []
|
||||
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
|
||||
|
||||
[dependencies]
|
||||
portable-pty = { version = "0.9", optional = true }
|
||||
tokio-util = { version = "0.7", features = ["io"], optional = true }
|
||||
```
|
||||
|
||||
A consumer that wants the local backend enables `features = ["local"]`
|
||||
on alktty and gets `alktty::local::LocalTtyBackend`. A consumer that
|
||||
only wants docker/ssh backends uses the default features and depends on
|
||||
`alknet-docker` / `alknet-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; the
|
||||
feature gate is the only thing keeping `portable_pty` out of the
|
||||
default crate. Folding the local backend in is cheaper than the alknet
|
||||
sibling-crate pattern (no cross-crate coordination, no cyclic-dep
|
||||
workaround) and the local backend is small (~1.4k lines) and tightly
|
||||
coupled to the trait.
|
||||
|
||||
### 2. The local backend's dependency tree
|
||||
|
||||
```
|
||||
alktty (local feature enabled)
|
||||
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
|
||||
├── alkcall::core (via alktty's re-export; not direct)
|
||||
├── portable_pty (PTY allocation — the heavy dep, here not in alktty default)
|
||||
├── libc (signal forwarding — REQ-TTY-02, Unix only)
|
||||
└── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)
|
||||
```
|
||||
|
||||
The `local` feature is inherently non-wasm (`portable-pty` +
|
||||
`tokio::process` need a real OS); enabling `local` on
|
||||
`wasm32-unknown-unknown` is a build error by design. The default crate
|
||||
(no features) stays wasm-clean — this is what makes the downstream
|
||||
TS/Python adapter story work (a wasm-compiled alktty is the protocol
|
||||
layer for a sandboxed adapter; the local-process backend runs on a real
|
||||
OS, never in a sandbox).
|
||||
|
||||
### 3. PTY vs pipe is a per-session choice, not a per-deployment choice
|
||||
|
||||
`TtyParams.terminal: Option<TerminalParams>` (ADR-002) 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. `tokio::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 alk'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-004).
|
||||
- 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 alktty**. alktty 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
|
||||
alktty. See OQ-46.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- alktty's default dependency surface is minimal (alkcall + 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
|
||||
alktty. The mechanism is the framed byte stream; the policy is
|
||||
downstream.
|
||||
- The single-crate placement composes with alknet ADR-003's
|
||||
no-handler-depends-on-another-handler rule: the local backend is in
|
||||
the same crate as the trait, so there is no cross-crate dependency
|
||||
edge to worry about. The docker and SSH backends remain separate
|
||||
crates (real external deps — `bollard`, `russh` — and their own
|
||||
resource models).
|
||||
- The alktty consolidation resolves the alknet cyclic-dep workaround
|
||||
that motivated the original sibling-crate decision. No assembly-layer
|
||||
"depend on both crates directly" pattern; no cargo-rejected
|
||||
feature-re-export-from-an-optional-dep dance.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- A consumer that wants the local backend must enable a feature flag
|
||||
(`features = ["local"]`). Forgetting the flag results in the
|
||||
`alktty::local` module not existing — a compile error, not a silent
|
||||
miss. This is the standard Rust feature-flag trade and is
|
||||
self-documenting.
|
||||
- The `local` feature is non-wasm by design. A consumer that targets
|
||||
`wasm32-unknown-unknown` must not enable `local` — the build error is
|
||||
intentional (the local-process backend cannot run in a sandbox). The
|
||||
default crate stays wasm-clean so the downstream TS/Python adapter
|
||||
story works.
|
||||
- The runner-specific API surface (job management, log persistence) is
|
||||
not in alktty. 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 alktty. See OQ-46.
|
||||
|
||||
## Door type
|
||||
|
||||
**Two-way.** The single-crate-with-feature-gate placement is
|
||||
reversible: if the local backend ever grows large enough to warrant
|
||||
splitting out (e.g., a future docker or SSH backend shares enough
|
||||
portable-pty bridge code to motivate a shared "blocking-backend bridge"
|
||||
crate), extracting `src/local/` into a sibling crate behind the same
|
||||
`local` feature is mechanical — the trait is the seam, and the feature
|
||||
gate already exists. The cost of reversal is low (the module becomes a
|
||||
crate; the feature gate switches from `dep:portable-pty` to
|
||||
`dep:alktty-local`), and no downstream consumer breaks (the
|
||||
`alktty::local::LocalTtyBackend` path stays valid if the sibling crate
|
||||
re-exports it).
|
||||
|
||||
This is a two-way door that is **decided** (single crate + `local`
|
||||
feature), not deferred. The decision is made now; the reversal is cheap
|
||||
if a future split warrants it. See alknet ADR-009 §"What this framework
|
||||
is NOT" — door type classifies reversal cost, not urgency.
|
||||
|
||||
## References
|
||||
|
||||
- alknet ADR-003 + Amendments 1 & 2 — crate decomposition rule (the
|
||||
single-crate placement preserves it; the local backend is in the same
|
||||
crate as the trait, not a separate crate that depends back)
|
||||
- alknet ADR-009 — door-type-as-deferral anti-pattern (this ADR's
|
||||
two-way-door classification is reversal cost, not a deferral)
|
||||
- alknet ADR-017 — the adapter-location-map pattern (trait where types
|
||||
live, implementation where deps live) this ADR follows (the `local`
|
||||
feature is where the deps live)
|
||||
- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
|
||||
trait this module implements
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the
|
||||
adapter pumps to/from this backend
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk
|
||||
ordering the local backend's waiter thread feeds
|
||||
- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel-
|
||||
cleanup contract the local backend's `exit_code` future's `Drop`
|
||||
implements
|
||||
- OQ-46 — runner API surface (deferred(scope): mechanism in alktty,
|
||||
policy is a downstream crate)
|
||||
- Spec: [tty-local.md](../tty-local.md)
|
||||
- Port origin: alknet ADR-054 at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/054-local-tty-backend-sibling-crate.md`
|
||||
Reference in New Issue
Block a user