Add M1 plan, given tests and fixtures, AGENTS.md and implementer log
Seven task files for the implementing model under docs/plans/M1/, with the test files, byte-exact fixtures, Makefile, deny.toml and gate-script self-test they copy into place. All of it was verified against a private reference implementation: the gate passes after every task in order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# M1 task 04: `proto` frame codec
|
||||
|
||||
**Branch:** `m1` (run `git switch m1`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `Add length-prefixed frame codec to proto`
|
||||
|
||||
## Goal
|
||||
|
||||
Read and write one envelope on a byte stream. This is the only I/O in `proto`. It is the first code
|
||||
that touches bytes from another process, so it must never trust a length it has not checked.
|
||||
|
||||
## Context
|
||||
|
||||
From the spec: "A 4-byte big-endian unsigned length `N`, then `N` bytes of UTF-8 JSON holding one
|
||||
envelope. `MAX_FRAME` is 1,048,576. A reader that sees `N == 0` or `N > MAX_FRAME` returns an error
|
||||
without reading or allocating the body." And: "`v` is the protocol version, 1. A reader that sees
|
||||
another value" rejects the frame.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/proto/tests/frame.rs`, `crates/proto/tests/fixtures/frame/tool_request.bin`
|
||||
- Create: `crates/proto/src/frame.rs`
|
||||
- Modify: `crates/proto/src/lib.rs`, `docs/implementer-log.md`
|
||||
|
||||
## Interfaces
|
||||
|
||||
Consumes from task 03: `proto::Envelope`, `proto::PROTOCOL_VERSION`.
|
||||
|
||||
Produces, in `crates/proto/src/frame.rs`, re-exported from the crate root:
|
||||
|
||||
```rust
|
||||
pub const MAX_FRAME: usize = 1_048_576;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FrameError {
|
||||
Closed, // the stream ended before the first byte of a frame
|
||||
Empty, // the length prefix was 0
|
||||
TooLarge(usize), // the length prefix, or an outgoing body, was larger than MAX_FRAME
|
||||
BadVersion(u32), // the envelope's v was not PROTOCOL_VERSION
|
||||
Json(serde_json::Error), // the body was not a valid envelope, or could not be encoded
|
||||
Io(std::io::Error), // any other I/O failure, including a stream that ends mid-frame
|
||||
}
|
||||
// FrameError implements Display (a short message per variant) and std::error::Error.
|
||||
|
||||
pub fn write_frame<W: std::io::Write>(w: &mut W, env: &Envelope) -> Result<(), FrameError>;
|
||||
pub fn read_frame<R: std::io::Read>(r: &mut R) -> Result<Envelope, FrameError>;
|
||||
```
|
||||
|
||||
Rules the tests check:
|
||||
|
||||
- `write_frame` encodes first. If the body is larger than `MAX_FRAME` it returns `TooLarge(len)`
|
||||
and writes **nothing**. Otherwise it writes the 4-byte length, the body, and flushes.
|
||||
- `read_frame` reads exactly 4 length bytes. `Read::read` may return fewer bytes than asked for,
|
||||
even one at a time; keep reading until you have all 4. If the stream ends before the first of
|
||||
them, return `Closed`. If it ends after 1 to 3 of them, return `Io`.
|
||||
- It checks `N == 0` (`Empty`) and `N > MAX_FRAME` (`TooLarge(N)`) **before** it allocates a
|
||||
buffer or reads the body. One test uses a reader that panics if the body is read.
|
||||
- A body of exactly `MAX_FRAME` bytes is allowed.
|
||||
- A stream that ends inside the body is `Io`. A body that is not a valid envelope is `Json`.
|
||||
- After decoding, `v != PROTOCOL_VERSION` is `BadVersion(v)`.
|
||||
|
||||
## API notes
|
||||
|
||||
- `u32::from_be_bytes([u8; 4])` and `u32::to_be_bytes()`.
|
||||
- `serde_json::to_vec(&T) -> Result<Vec<u8>, serde_json::Error>` and
|
||||
`serde_json::from_slice::<T>(&[u8])`.
|
||||
- `Read::read_exact` returns `ErrorKind::UnexpectedEof` when the stream ends early, which is what
|
||||
you want for the body. It cannot tell a clean close from a cut-off header, so do not use it for
|
||||
the 4 length bytes.
|
||||
- A `read` that fails with `ErrorKind::Interrupted` should be retried.
|
||||
- Build an `io::Error` from a kind with `std::io::Error::from(std::io::ErrorKind::UnexpectedEof)`.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy the test and fixture.**
|
||||
|
||||
```sh
|
||||
git switch m1
|
||||
cp docs/plans/M1/files/crates/proto/tests/frame.rs crates/proto/tests/
|
||||
cp -r docs/plans/M1/files/crates/proto/tests/fixtures/frame crates/proto/tests/fixtures/
|
||||
```
|
||||
|
||||
- [ ] **2. See the test fail.** `cargo test -p proto --test frame`. Expected: it does not compile.
|
||||
|
||||
- [ ] **3. Write `frame.rs`,** and add to `lib.rs` (alphabetical order, as before):
|
||||
|
||||
```rust
|
||||
pub mod frame;
|
||||
|
||||
pub use frame::{FrameError, MAX_FRAME, read_frame, write_frame};
|
||||
```
|
||||
|
||||
- [ ] **4. See the test pass.** `cargo test -p proto --test frame`. Expected: `13 passed; 0 failed`.
|
||||
|
||||
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||
|
||||
- [ ] **6. Log and commit.**
|
||||
|
||||
```sh
|
||||
git add crates/proto docs/implementer-log.md
|
||||
git commit
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- `cargo test -p proto --test frame` reports 13 passed.
|
||||
- `make gate` prints `gate: ok`.
|
||||
- `cmp crates/proto/tests/frame.rs docs/plans/M1/files/crates/proto/tests/frame.rs` prints nothing.
|
||||
- `frame.rs` contains no `unwrap`, `expect`, `panic!` or unchecked indexing.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- `write_matches_the_fixture_byte_for_byte` fails although `cargo test -p proto --test wire` passes.
|
||||
Reference in New Issue
Block a user