11 Commits
Author SHA1 Message Date
glm-5.2 c2b7055a64 refactor(tty): split control channel into STREAM_CTRL_IN/STREAM_CTRL_OUT halves (Phase 7)
The single STREAM_CONTROL = 3 was documented as bidirectional but the
adapter had to ignore Exit from the client because the two directions
were indistinguishable on the same stream_type — half-duplex in
disguise. Phase 7 splits it into two halves so the bidirectionality is
literal on the wire.

Changes:
- wire.rs: STREAM_CTRL_IN = 3 (client→server), STREAM_CTRL_OUT = 4
  (server→client); InvalidStreamType bound > 3 → > 4; Chunk::control
  → Chunk::ctrl_in/ctrl_out; ChunkWriter::write_control_json →
  write_ctrl_in_json/write_ctrl_out_json; tests split accordingly.
- control.rs: ControlMessage doc updated with the stream_type column;
  JSON shape unchanged.
- adapter.rs: pump_client_to_backend dispatches on STREAM_CTRL_IN
  (Resize/Signal/Eof; Exit on ctrl_in is a protocol violation,
  ignored); send_exit_chunk emits on STREAM_CTRL_OUT; STREAM_CTRL_OUT
  from the client is a protocol violation, ignored. 3 new tests for
  the direction enforcement; existing tests updated to the new
  stream_types.
- negotiation.rs: framing-disambiguation doc updated (server-sent
  stream_type set is {1, 2, 4}).
- alknet-tty-local/tests: common/mod.rs, pty.rs, pipe.rs updated to
  the new constants.

Specs:
- ADR-052 amended (§4a 'Control channel split (Phase 7 amendment)').
- tty-wire.md + tty-adapter.md updated (last_updated 2026-07-18).

Verification:
- cargo test -p alknet-tty: 65 passed (was 61; +4 new tests).
- cargo test -p alknet-tty-local: 19 passed.
- cargo test --workspace --all-features: 1017 passed, 0 failed.
- cargo clippy --workspace --all-features: clean.
- cargo fmt --all: clean.
2026-07-18 17:02:59 +00:00
glm-5.2 b60a5844ba refactor(core,http,tty,call): unify stream leaf — BiStream as the handler leaf (Phase 6)
Implement ADR-092 across the workspace: accept_bi/open_bi return BiStream
(a concrete AsyncRead + AsyncWrite + Send + Unpin newtype), not the split
(SendStream, RecvStream) pair. The join moves into core's BidiStreamSource
impls (quinn/iroh via tokio::io::join, single-stream via boxed AsyncReadWrite);
handlers receive the joined BiStream and never see the pair.

Core (alknet-core/src/types.rs):
- Add concrete BiStream struct boxing Box<dyn AsyncReadWrite + Unpin>,
  with AsyncRead + AsyncWrite impls. from_joined (pub, for downstream
  crates that produce split halves naturally — channels reassembly, tests)
  and from_bidi (pub(crate), for Connection::from_bidi) constructors.
- Change BidiStreamSource::accept_bi/open_bi return types from
  (SendStream, RecvStream) to BiStream. Update QuinnBidiStreamSource,
  IrohBidiStreamSource, StreamBidiStreamSource impls to do the join once.
- Collapse SendStream/RecvStream to thin newtypes over
  Box<dyn Async* + Send + Unpin>. Remove SendStreamKind/RecvStreamKind
  enums and the quinn/iroh per-call dispatch (the join happens once in the
  BidiStreamSource impl now). Keep SendStream::from_stream /
  RecvStream::from_stream per-half boxing for into_sub_streams() (ADR-074)
  and the future channels reassembly path.
- Remove Connection::from_stream (split-pair constructor). Promote
  Connection::from_bidi to the only public stream constructor (the rule:
  the split never crosses a crate boundary as part of a constructor).
- Update Connection::accept_bi/open_bi to return BiStream. Update
  from_source_tests and tests modules to use from_bidi and BiStream;
  add a SinkEmpty test helper (AsyncRead EOF + AsyncWrite discard) for
  Connection-level-only test connections.

alknet-http (server/adapter.rs):
- Drop the 44-line QuicStream wrapper — accept_bi returns BiStream which
  is already AsyncRead + AsyncWrite. HttpAdapter::handle becomes 4 lines.
- Drop the 38-line QuicStreamDuplex test helper — tests use a single
  tokio::io::duplex whose ends are each AsyncRead + AsyncWrite natively.
- Remove unused std::io / std::pin::Pin imports.

alknet-tty (adapter.rs):
- TtyAdapter::handle splits the BiStream from accept_bi via
  tokio::io::split for drive_session's separate AsyncWrite/AsyncRead args
  (the stdlib idiom for TcpStream-style duplex streams).

alknet-call (protocol/*, client/*):
- Dispatcher::run_loop accept_bi site: take BiStream, pass to handle_stream.
- Dispatcher::handle_stream signature: take BiStream, split internally via
  tokio::io::split (was: take SendStream + RecvStream separately).
- CallConnection::call_with_payload / subscribe_with_payload / write_envelope:
  split the BiStream from open_bi via tokio::io::split at the call site.
- write_request / read_stream_until_closed: generic over AsyncWrite/AsyncRead
  (were: concrete SendStream/RecvStream) — accepts the ReadHalf/WriteHalf
  from tokio::io::split directly.
- Add protocol/test_support.rs with sink_empty_connection() (replaces the
  5 duplicated stub_connection() fns that used Connection::from_stream).
- Update all test stubs (call_client.rs, protocol/connection.rs,
  protocol/dispatch.rs, protocol/adapter.rs, client/from_call.rs) to use
  Connection::from_bidi + the shared sink_empty_connection() helper.
- Test handle_stream call sites: build BiStream::from_joined(recv, send)
  from the existing BufReader<Cursor> + duplex pair.

Workspace test status: all 9 crates pass (116 + 307 + 18 + 3 + 17 + 301 +
34 + 61 + 23 + 5 + 6 + 8 + 82 + 4 + 3 + 6 + 12 + 1 = 1007 tests pass). One
pre-existing failure remains in alknet-http
(adapters::to_mcp::tests::search_returns_access_control_filtered_ops_excluding_subscriptions
— handler kind mismatch, unrelated to Phase 6, fails on develop baseline).
2026-07-18 15:59:01 +00:00
glm-5.2 a20efb4d06 feat(tty): wire local feature gate with assembly-layer re-export pattern
ADR-054 prescribes  with a re-export
module, but cargo rejects the cyclic dependency (alknet-tty →
alknet-tty-local → alknet-tty) even with an optional dep. The re-export
is deferred to the assembly layer: consumers depend on alknet-tty-local
directly and register LocalTtyBackend in the TtyAdapter backend map.
The  feature gate stays declared (empty) for forward compatibility.
Assembly pattern documented in the crate root doc comment.
2026-07-07 23:25:08 +00:00
glm-5.2 d5612f1a0e feat(tty): implement TtyAdapter (ProtocolHandler) and three-pump session driver (ADR-052, 053, 055, 056)
Implement TtyAdapter (ProtocolHandler on alknet/tty) and the
drive_session three-pump bidirectional driver in adapter.rs.

TtyAdapter holds a HashMap<String, Arc<dyn TtyBackend>> keyed by the
negotiation frame's backend string and an optional OwnershipProvider.
handle() loops accept_bi and spawns drive_session per stream.

drive_session proceeds in three phases:
1. Negotiation — read length-prefixed JSON, parse NegotiateRequest,
   validate (carriage == raw, cmd non-empty), look up backend, scope-gate
   (tty:open), resource ownership check via backend.resource_id() +
   OwnershipProvider::owns(), construct TtyParams. Errors sent as JSON
   in negotiation framing, stream closed.
2. Allocation — backend.allocate(&params). On failure send
   allocate_failed error, close.
3. Raw carriage — three concurrent pumps:
   A. stdout/stderr → client (stream_type 1/2), zero-length sentinel on EOF
   B. client → backend: stdin chunks → TtyHandle.stdin, control chunks →
      Resize/Signal/Eof dispatch (Exit ignored, unknown types ignored)
   C. exit → exit chunk: await exit_code; on resolve enqueue exit chunk

Exit-chunk-is-last invariant (ADR-055): adapter waits for BOTH
stdout/stderr pumps to complete AND exit_code to resolve before
enqueueing the exit chunk (tokio::join!). On TtyError → code -1.

Cancel cleanup (ADR-056): on connection drop/stream reset, pump tasks
drop, TtyHandle drops, exit_code future drops without resolve →
backend kill-on-Drop fires. Client write-half close is NOT a cancel —
session runs to completion.

Access control (ADR-050): scope gate at negotiation (tty:open scope,
configurable constant), resource ownership via backend.resource_id() +
OwnershipProvider::owns() when wired.

18 integration tests with a TestBackend fixture over tokio::io::duplex:
happy path, exit-chunk-is-last, stdin EOF, resize/signal control,
unknown control ignored, exit control ignored, unknown_backend,
malformed_negotiation (bad JSON, carriage != raw, empty cmd),
allocate_failed, exit error (-1), cancel cleanup, scope gate forbidden,
ownership check deny/allow, stderr pump, eof control closes stdin.

Also fix two clippy unused_mut warnings in negotiation.rs tests.
2026-07-07 15:34:09 +00:00
glm-5.2 491ff1e6f8 feat(tty): implement negotiation frame — NegotiateRequest, framing reader/writer, error response (ADR-052, ADR-057)
Replace placeholder negotiation types with the full Phase 1 wire shape:
- NegotiateRequest/TerminalParamsWire with serde(flatten) capturing
  backend-specific fields into backend_params
- NegotiationReader/Writer: self-contained 4-byte BE length-prefixed
  framing on tokio AsyncRead/AsyncWrite, bounds-checked against MAX_CHUNK_LEN
- NegotiationError (Io, ConnectionClosed, FrameTooLarge, Json) via thiserror
- error_response_bytes helper for server-side JSON error frames
- into_inner reclaims the stream for raw-chunk use
- Error frames stay under 16 MiB so the high byte of the length prefix is
  0x00, making the framing-disambiguation trick sound (ADR-052 §5)

Unit tests cover round-trip, serde(flatten) capture, FrameTooLarge,
ConnectionClosed on truncated header/body, error-response shape, and the
0x00-first-byte framing-disambiguation invariant. backend.rs's
From<NegotiateRequest> for TtyParams still compiles (same field shapes).
2026-07-07 14:19:49 +00:00
glm-5.2 f72b80c451 Merge remote-tracking branch 'origin/feat/tty/backend-trait' into develop 2026-07-07 14:08:45 +00:00
glm-5.2 b5834d3385 feat(tty): implement TtyBackend trait, TtyHandle, TtyControl, TtyParams, TtyError (ADR-053)
Implements the one-way-door backend inversion point in crates/alknet-tty/src/backend.rs:

- TtyBackend trait (#[async_trait]): allocate() + resource_id() default None
- TtyError enum (#[non_exhaustive]): AllocFailed, WaitFailed, Io, Backend
- TtyParams + TerminalParams (allocation request shapes)
- TtyHandle: stdin (AsyncWrite), stdout/stderr (Stream<Bytes>),
  exit_code (BoxFuture<Result<i32,TtyError>>), control (Option<TtyControlHandle>)
- TtyControl trait (object-safe, NOT Clone) + TtyControlHandle newtype
  (#[derive(Clone)] wrapping Arc<dyn TtyControl + Send + Sync>)
- From<NegotiateRequest> for TtyParams and From<TerminalParamsWire> for
  TerminalParams (adapter uses these; no hand-rolled mapping)
- MockBackend (in-memory mpsc pipes + oneshot exit + MockControl) for
  compile-check and adapter tests
- Unit tests: TtyControlHandle clone+delegation, MockBackend allocate/exit,
  resource_id default, From<NegotiateRequest> mapping (pty + pipe modes)

TtyBackend trait doc carries REQ-TTY-01 (backends need not be natively
async); TtyHandle.exit_code doc carries the ADR-056 kill-on-Drop contract.

negotiation.rs carries minimal NegotiateRequest/TerminalParamsWire
placeholders (field set per tty-wire.md) so the From conversion compiles
before the negotiation task lands; that task replaces them with the full
framing machinery.
2026-07-07 13:54:36 +00:00
glm-5.2 ae75a61722 Merge remote-tracking branch 'origin/feat/tty/control-messages' into develop 2026-07-07 13:52:01 +00:00
glm-5.2 8b8de61186 feat(tty): implement ControlMessage enum and signal_from_name
Port the POC's control channel schema (alknet-tty-poc/src/control.rs)
into crates/alknet-tty/src/control.rs: the ControlMessage tagged enum
(Resize/Signal/Eof/Exit) with snake_case type tag, to_json/from_slice
helpers, and the Unix-only signal_from_name mapping the 9 supported
signal names to libc numbers. Unknown type tags return a serde_json
error; the adapter (later task) ignores that error per the wire spec's
extensibility policy. Adds libc as a target.'cfg(unix)' dependency.

Refs: docs/architecture/crates/tty/tty-wire.md §"Control Channel"
Refs: docs/architecture/decisions/055-exit-code-on-control-chunk.md
2026-07-07 13:51:32 +00:00
glm-5.2 17713a5c27 feat(tty): implement raw chunk codec (ChunkReader/ChunkWriter, RawError)
Port of the alknet-tty POC's raw.rs into crates/alknet-tty/src/wire.rs.
The Phase 2 'raw carriage' wire format (ADR-052):
[stream_type: u8][length: u32 be][payload bytes].

- STREAM_STDIN/STDOUT/STDERR/CONTROL constants (0-3)
- CHUNK_HEADER_LEN (5) and MAX_CHUNK_LEN (16 MiB)
- RawError: Io / ConnectionClosed / InvalidStreamType / ChunkTooLarge
- Chunk struct with stdin/stdout/stderr/control constructors
- ChunkReader::read_chunk validates stream_type and length, returns
  ConnectionClosed on clean UnexpectedEof (header or payload)
- ChunkWriter::write_chunk/write_stdin/write_control_json write
  header + payload + flush
- Round-trip tests for all four stream types, InvalidStreamType (4),
  ChunkTooLarge (> MAX_CHUNK_LEN), ConnectionClosed on truncated
  header and payload, via tokio::io::duplex
2026-07-07 13:51:31 +00:00
glm-5.2 da6333c206 feat(tty): initialize alknet-tty crate skeleton
Create crates/alknet-tty with Cargo.toml (alknet-core path dep, tokio,
bytes, futures-core, tokio-stream, serde, serde_json, async-trait,
tracing, thiserror — no portable_pty/bollard/russh/alknet-call per
ADR-057), the `local` feature gate (dep wiring deferred to
tty/local-feature-reexport), src/lib.rs with the crate doc comment and
module declarations, and skeleton modules (wire, control, negotiation,
backend, adapter) with doc comments and TODO markers. Add the crate to
the workspace members list.

cargo check, clippy -D warnings, and fmt --check all pass.
2026-07-07 13:46:11 +00:00