feat(session_protocol): PR 13a — Wave 2 envelope spec freeze + ref impl
PYTHON_THINNING_PLAN §5 PR 13a (Wave 2 게이트). 4-team SYNTHESIS 합의:
spec drift 방지를 위해 envelope 스펙 + 최소 reference impl을 별도 PR로
분리. PR 13a land *후*에야 PR 16 (PR-A 본체)이 envelope 표준에
정합하게 빚어진다는 보장.
산출물:
- rust/crates/session_protocol/src/envelope.rs 신설:
- ``Envelope { v, channel, kind, body }`` struct (serde Derive).
- ``Envelope::new(channel, kind, body)`` — `v` 자동으로
``CHANNEL_ENVELOPE_V1`` 으로 stamp (stale version 방지).
- ``Envelope::is_current_version()`` — forward-compat marker 검증.
- ``reference_dispatch(&Envelope) -> Envelope`` 최소 channel router:
- control / echo → echo_response (body reflected)
- 미지원 channel/kind → channel_kind_unhandled error envelope
- stale `v` → envelope_version_mismatch error envelope
- 7 단위 테스트 (round-trip, version reject, control echo, error shape,
null body, lenient extra-field parse).
- rust/crates/session_protocol/src/lib.rs:
- ``pub mod envelope`` + ``pub use envelope::{Envelope,
reference_dispatch}`` re-export.
- rust/crates/session_protocol/tests/envelope_parity.rs 신설 (5 테스트):
- byte-for-byte NDJSON shape pin (4 field 순서 + value).
- reference_dispatch round-trip / version reject / unknown channel.
- cross-crate import 경로 검증 (PR 13b/PR 16에서 같은 경로 사용).
PR 13b 후속 (Wave 2 envelope 완전 구현):
- file / exec_once / lsp:* channel handlers 추가.
- per-request timeout / 취소 / 우선순위 / back-pressure.
- session_helper 측 cancellation hook (현재 lib.rs:215 "not yet implemented").
PR 16 후속 (PR-A 본체):
- ``sessions_orchestrator`` crate가 control 채널을 통해 worker queue
dispatch. envelope shape 정합 보장은 PR 13a 의 reference_dispatch가
컴파일 시점에 강제.
테스트: cargo test --workspace 그린 (session_protocol 5 신규 + 기존 64
+ envelope.rs 단위 7 + 다른 crate 그대로). clippy 그린 (테스트 시그니처
``Result<(), serde_json::Error>`` + ``?`` 패턴 — workspace
``unwrap_used / expect_used = "deny"`` 정합).
boundary-claim:
removes: []
delete-count: 0
ban-list: 'Wave 2 envelope spec freeze — PR 16 (PR-A) 게이트'
rust-additions: ~250 LOC (envelope.rs + parity tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
> | PR 10 | ✅ | `b47f7eb` | file_state parity tests +26 (총 33 시나리오, amend §D paired) |
|
||||
> | PR 11 | ✅ | `859c413` | file_state kind_codes 3중 복제 통합 + decision 매핑 lookup table (-85 LOC) |
|
||||
> | PR 12 | ✅ | `92dd66a` | eager_hydrate parity tests +19 (총 33 시나리오, amend §D paired) |
|
||||
> | **PR 13a** | ⏭ Wave 2 게이트 | — | envelope 스펙 + ref impl + parity (PR-A 본체 가능) |
|
||||
> | **PR 13a** | ✅ Wave 2 게이트 | (TBD) | envelope 스펙 freeze + reference_dispatch + parity test 5개 |
|
||||
> | PR 13b | ⏭ Wave 2 | — | envelope 완전 구현 (취소·deadline·우선순위) |
|
||||
> | PR 14 | ⏭ Wave 2 | — | eager_hydrate BFS → mirror 통합 |
|
||||
> | PR 14.5 | ⏭ | — | H1 file_open transaction (silent corruption) |
|
||||
|
||||
199
rust/crates/session_protocol/src/envelope.rs
Normal file
199
rust/crates/session_protocol/src/envelope.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Multiplex envelope (Wave 2 spec freeze — PYTHON_THINNING_PLAN.md §5 PR 13a).
|
||||
//!
|
||||
//! The envelope is the on-wire shape that lets a single `local_bridge ↔
|
||||
//! session_helper` stdio link carry multiple logical channels (file, exec_once,
|
||||
//! lsp, control, future mirror) without one slow run blocking interactive
|
||||
//! traffic. Wave 2 builds cancellation, deadlines, and back-pressure on top of
|
||||
//! this shape; freezing it now (PR 13a) lets PR 16 (PR-A worker loop body)
|
||||
//! land while PR 13b adds the rest of Wave 2 incrementally.
|
||||
//!
|
||||
//! ## Wire shape
|
||||
//!
|
||||
//! ```text
|
||||
//! { "v": "sessions.channel.v1",
|
||||
//! "channel": "control", // "file" / "exec_once" / "lsp:<id>" / ...
|
||||
//! "kind": "request", // "lsp_stdio.ping" / etc.
|
||||
//! "body": { ... } } // channel/kind-specific payload
|
||||
//! ```
|
||||
//!
|
||||
//! `v` is the [`crate::CHANNEL_ENVELOPE_V1`] constant so future revisions can
|
||||
//! be detected at parse time. `channel` and `kind` are free-form strings; the
|
||||
//! crate-level `CHANNEL_*` and `CHANNEL_KIND_*` constants define the shapes
|
||||
//! every helper/bridge implementation must already accept.
|
||||
//!
|
||||
//! ## Spec drift guard
|
||||
//!
|
||||
//! `Envelope` is the **single source of truth** for the wire shape. Any code
|
||||
//! that builds or parses these four fields must round-trip through
|
||||
//! `serde_json::to_value` / `serde_json::from_value` of this struct — see
|
||||
//! `tests/envelope_parity.rs` for the parity contract that PR 16 (PR-A) will
|
||||
//! reuse to ensure its supervisor stays envelope-compatible.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::CHANNEL_ENVELOPE_V1;
|
||||
|
||||
/// Multiplex envelope wire shape (Wave 2 spec freeze).
|
||||
///
|
||||
/// Constructed via [`Envelope::new`] (which fills `v` with the canonical
|
||||
/// version constant) or directly from raw JSON via `serde_json::from_value`.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
pub struct Envelope {
|
||||
/// Envelope version. Always [`CHANNEL_ENVELOPE_V1`] for the Wave 2 freeze.
|
||||
pub v: String,
|
||||
/// Logical channel routing the envelope (e.g. `"file"`, `"control"`,
|
||||
/// `"lsp:<server-id>"`).
|
||||
pub channel: String,
|
||||
/// Channel-specific message kind (e.g. `"request"`, `"lsp_stdio.ping"`).
|
||||
pub kind: String,
|
||||
/// Opaque per-(channel, kind) payload. May be any JSON value, including
|
||||
/// `null` for no-body messages such as control pings.
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
/// Build an envelope with `v` set to [`CHANNEL_ENVELOPE_V1`].
|
||||
///
|
||||
/// Prefer this over a raw struct literal so callers cannot accidentally
|
||||
/// stamp a stale envelope version onto a new message.
|
||||
#[must_use]
|
||||
pub fn new(channel: impl Into<String>, kind: impl Into<String>, body: Value) -> Self {
|
||||
Self {
|
||||
v: CHANNEL_ENVELOPE_V1.to_string(),
|
||||
channel: channel.into(),
|
||||
kind: kind.into(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return whether `self.v` matches [`CHANNEL_ENVELOPE_V1`].
|
||||
///
|
||||
/// Wave 2 reference implementations should reject envelopes with an
|
||||
/// unknown `v` (forward-compat marker for a future rev).
|
||||
#[must_use]
|
||||
pub fn is_current_version(&self) -> bool {
|
||||
self.v == CHANNEL_ENVELOPE_V1
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference implementation of the Wave 2 envelope router (PR 13a).
|
||||
///
|
||||
/// Routes one envelope to its channel handler and returns a response envelope
|
||||
/// (or an error envelope) on the same channel. The Wave 2 freeze ships exactly
|
||||
/// one channel handler — `"control"`, which echoes the request body — so the
|
||||
/// router covers every channel/kind path that the parity test exercises while
|
||||
/// staying small enough to be reviewed in PR 13a.
|
||||
///
|
||||
/// PR 13b extends this with the `file` / `exec_once` / `lsp:*` channels;
|
||||
/// PR 16 plugs the orchestrator into the `control` channel for queue
|
||||
/// dispatch. The shape of the function — `Envelope -> Envelope` — is the
|
||||
/// `compile-time spec drift guard` rust-maximalist asked for: any future
|
||||
/// channel handler that wants to live on this transport must accept and
|
||||
/// return [`Envelope`] (not raw JSON).
|
||||
pub fn reference_dispatch(request: &Envelope) -> Envelope {
|
||||
if !request.is_current_version() {
|
||||
return Envelope::new(
|
||||
request.channel.clone(),
|
||||
"error",
|
||||
serde_json::json!({
|
||||
"code": "envelope_version_mismatch",
|
||||
"expected": CHANNEL_ENVELOPE_V1,
|
||||
"received": request.v,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if request.channel == "control" && request.kind == "echo" {
|
||||
return Envelope::new("control", "echo_response", request.body.clone());
|
||||
}
|
||||
Envelope::new(
|
||||
request.channel.clone(),
|
||||
"error",
|
||||
serde_json::json!({
|
||||
"code": "channel_kind_unhandled",
|
||||
"channel": request.channel,
|
||||
"kind": request.kind,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_stamps_current_version() {
|
||||
let env = Envelope::new("control", "echo", Value::Null);
|
||||
assert_eq!(env.v, CHANNEL_ENVELOPE_V1);
|
||||
assert!(env.is_current_version());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_through_json_value() -> Result<(), serde_json::Error> {
|
||||
let env = Envelope::new("control", "echo", serde_json::json!({"x": 1}));
|
||||
let value = serde_json::to_value(&env)?;
|
||||
let back: Envelope = serde_json::from_value(value)?;
|
||||
assert_eq!(env, back);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_through_ndjson_string() -> Result<(), serde_json::Error> {
|
||||
let env = Envelope::new("file", "request", serde_json::json!({"path": "/a"}));
|
||||
let line = serde_json::to_string(&env)?;
|
||||
let back: Envelope = serde_json::from_str(&line)?;
|
||||
assert_eq!(env, back);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_version_in_dispatch() {
|
||||
let req = Envelope {
|
||||
v: "sessions.channel.v999".to_string(),
|
||||
channel: "control".to_string(),
|
||||
kind: "echo".to_string(),
|
||||
body: Value::Null,
|
||||
};
|
||||
let resp = reference_dispatch(&req);
|
||||
assert_eq!(resp.kind, "error");
|
||||
assert_eq!(resp.body["code"], "envelope_version_mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_echo_reflects_body() {
|
||||
let req = Envelope::new("control", "echo", serde_json::json!({"hello": "world"}));
|
||||
let resp = reference_dispatch(&req);
|
||||
assert_eq!(resp.kind, "echo_response");
|
||||
assert_eq!(resp.body, serde_json::json!({"hello": "world"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_channel_kind_returns_error() {
|
||||
let req = Envelope::new("file", "tree/list", Value::Null);
|
||||
let resp = reference_dispatch(&req);
|
||||
assert_eq!(resp.kind, "error");
|
||||
assert_eq!(resp.body["code"], "channel_kind_unhandled");
|
||||
assert_eq!(resp.body["channel"], "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_body_round_trips_intact() -> Result<(), serde_json::Error> {
|
||||
let env = Envelope::new("control", "ping", Value::Null);
|
||||
let line = serde_json::to_string(&env)?;
|
||||
let back: Envelope = serde_json::from_str(&line)?;
|
||||
assert_eq!(back.body, Value::Null);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_fields_are_rejected_for_strict_freeze() -> Result<(), serde_json::Error> {
|
||||
// serde_json default for derive(Deserialize) ignores extra fields,
|
||||
// which is desirable for forward-compat. This test pins that
|
||||
// contract so PR 16 can rely on lenient parsing of unknown body
|
||||
// shapes without a proto rev.
|
||||
let raw = r#"{"v":"sessions.channel.v1","channel":"control","kind":"echo","body":null,"extra":42}"#;
|
||||
let env: Envelope = serde_json::from_str(raw)?;
|
||||
assert!(env.is_current_version());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,11 @@ use serde_json::Value;
|
||||
use std::str::Utf8Error;
|
||||
|
||||
pub mod compatibility;
|
||||
pub mod envelope;
|
||||
pub mod lsp_stdio_framing;
|
||||
|
||||
pub use compatibility::{HandshakeCompatibility, normalized_protocol_version};
|
||||
pub use envelope::{Envelope, reference_dispatch};
|
||||
pub use lsp_stdio_framing::{read_lsp_message, write_lsp_message};
|
||||
|
||||
/// Version string advertised by the first shared Sessions protocol skeleton.
|
||||
|
||||
93
rust/crates/session_protocol/tests/envelope_parity.rs
Normal file
93
rust/crates/session_protocol/tests/envelope_parity.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! Wave 2 envelope parity test (PR 13a — spec freeze gate).
|
||||
//!
|
||||
//! Wire-shape pin for the `v` / `channel` / `kind` / `body` envelope. Any
|
||||
//! future change to those four field names breaks this fixture by design —
|
||||
//! Wave 2 implementations (PR 13b channel supervisor, PR 16 PR-A worker
|
||||
//! body) must round-trip through this exact NDJSON shape, so the freeze
|
||||
//! lives here in tests rather than buried in implementation files.
|
||||
//!
|
||||
//! Internal serde behaviour is covered by `envelope::tests` inside the
|
||||
//! crate. This integration test exists for the *cross-crate parity*
|
||||
//! contract — it imports through the public `session_protocol` re-export
|
||||
//! exactly as `local_bridge` / `session_helper` / `sessions_native` will.
|
||||
|
||||
use session_protocol::{CHANNEL_ENVELOPE_V1, Envelope, reference_dispatch};
|
||||
|
||||
#[test]
|
||||
fn envelope_canonical_ndjson_shape_is_frozen() -> Result<(), serde_json::Error> {
|
||||
// The four-field shape every Wave 2 channel handler must accept. If you
|
||||
// need to extend the wire shape, bump CHANNEL_ENVELOPE_V1 and add a new
|
||||
// parity fixture below — do not edit this one.
|
||||
let canonical = serde_json::json!({
|
||||
"v": "sessions.channel.v1",
|
||||
"channel": "control",
|
||||
"kind": "echo",
|
||||
"body": {"hello": "world"},
|
||||
});
|
||||
let env: Envelope = serde_json::from_value(canonical.clone())?;
|
||||
assert_eq!(env.v, CHANNEL_ENVELOPE_V1);
|
||||
assert_eq!(env.channel, "control");
|
||||
assert_eq!(env.kind, "echo");
|
||||
assert_eq!(env.body, serde_json::json!({"hello": "world"}));
|
||||
|
||||
let re_serialized = serde_json::to_value(&env)?;
|
||||
assert_eq!(re_serialized, canonical);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_dispatch_round_trips_control_echo() {
|
||||
let request = Envelope::new(
|
||||
"control",
|
||||
"echo",
|
||||
serde_json::json!({"id": "req-1", "payload": [1, 2, 3]}),
|
||||
);
|
||||
let response = reference_dispatch(&request);
|
||||
assert!(response.is_current_version());
|
||||
assert_eq!(response.channel, "control");
|
||||
assert_eq!(response.kind, "echo_response");
|
||||
assert_eq!(
|
||||
response.body,
|
||||
serde_json::json!({"id": "req-1", "payload": [1, 2, 3]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_dispatch_rejects_stale_version() {
|
||||
let request = Envelope {
|
||||
v: "sessions.channel.v0".to_string(),
|
||||
channel: "control".to_string(),
|
||||
kind: "echo".to_string(),
|
||||
body: serde_json::Value::Null,
|
||||
};
|
||||
let response = reference_dispatch(&request);
|
||||
assert_eq!(response.kind, "error");
|
||||
assert_eq!(response.body["code"], "envelope_version_mismatch");
|
||||
// The error envelope itself is *current* version — only the rejected
|
||||
// request held the stale `v`.
|
||||
assert!(response.is_current_version());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_channel_kind_yields_structured_error_envelope() {
|
||||
let request = Envelope::new("file", "tree/list", serde_json::Value::Null);
|
||||
let response = reference_dispatch(&request);
|
||||
assert_eq!(response.kind, "error");
|
||||
assert_eq!(response.body["code"], "channel_kind_unhandled");
|
||||
// PR 13b will replace this branch with a real `file` channel handler.
|
||||
assert_eq!(response.body["channel"], "file");
|
||||
assert_eq!(response.body["kind"], "tree/list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ndjson_round_trip_preserves_byte_for_byte_field_names() -> Result<(), serde_json::Error> {
|
||||
// Byte-level pin: serde-derived Serialize emits keys in struct order.
|
||||
// PR 16 (PR-A) relies on this when comparing recorded fixtures.
|
||||
let env = Envelope::new("control", "echo", serde_json::json!({"x": 1}));
|
||||
let line = serde_json::to_string(&env)?;
|
||||
assert_eq!(
|
||||
line,
|
||||
r#"{"v":"sessions.channel.v1","channel":"control","kind":"echo","body":{"x":1}}"#,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user