···99 out: shuttle/src/gen
1010 opt:
1111 - bytes=.
1212+ # the fleet protocol is broker<->executor only (both Go); shuttle (Rust)
1313+ # only speaks the agent protocol, so keep fleet types out of its gen tree.
1414+ exclude_types:
1515+ - spindle.mill.v1
···11+syntax = "proto3";
22+33+package spindle.mill.v1;
44+55+import "buf/validate/validate.proto";
66+77+option go_package = "tangled.org/core/spindle/mill/proto/gen;millv1";
88+99+// Hello is the first frame an executor sends after dialing the mill. It
1010+// carries the static traits of the node plus a resume hint.
1111+message Hello {
1212+ uint32 protocol_version = 1;
1313+ // stable across reconnects (e.g. hostname / DID); the mill keys sessions on
1414+ // this so a brief blip reattaches the same node rather than creating a new one.
1515+ string node_id = 2 [(buf.validate.field).string.min_len = 1];
1616+ // engine names this node can run ("microvm", "nixery").
1717+ repeated string engines = 3;
1818+ // GOARCH of the node, so the mill won't place arch-incompatible jobs.
1919+ string arch = 4;
2020+ // the highest relay offset the executor believes it has sent; a resume hint.
2121+ uint64 last_offset = 5;
2222+}
2323+2424+// Resume is the mill's reply to Hello. The executor replays every buffered
2525+// relay entry with offset strictly greater than ack_offset before sending new
2626+// ones.
2727+message Resume {
2828+ uint64 ack_offset = 1;
2929+}
3030+3131+// EngineSnapshot is the changing per-engine state on a node. free_seats is a
3232+// coarse "can you take more" hint; 0 also means draining (the mill treats a
3333+// draining node as a full one until it leaves). The resource fields are coarse
3434+// budget headroom for ranking only; the executor makes the real yes/no call in
3535+// ReserveResult.
3636+message EngineSnapshot {
3737+ uint32 free_seats = 1;
3838+ int64 free_memory_mib = 2;
3939+ int64 free_vcpus = 3;
4040+ int64 free_disk_mib = 4;
4141+}
4242+4343+// NodeSnapshot is pushed on connect, periodically, and right after any state
4444+// change (reserve, commit, terminal).
4545+message NodeSnapshot {
4646+ string node_id = 1;
4747+ uint64 seq = 2;
4848+ map<string, EngineSnapshot> engines = 3;
4949+}
5050+5151+// ReserveSeat asks an executor to hold a seat for a job. Zero secrets ride this
5252+// message; the raw pipeline/workflow are carried as JSON since processPipeline
5353+// already round-trips them through JSON.
5454+message ReserveSeat {
5555+ string lease_id = 1 [(buf.validate.field).string.min_len = 1];
5656+ string target_engine = 2 [(buf.validate.field).string.min_len = 1];
5757+ string raw_pipeline_json = 3;
5858+ string raw_workflow_json = 4;
5959+ // the pipeline id (knot + rkey); the executor reconstructs the exact
6060+ // WorkflowId so its relayed status rows and log path match what the mill
6161+ // authored for "pending".
6262+ string knot = 5;
6363+ string rkey = 6;
6464+ uint32 ttl_seconds = 7;
6565+}
6666+6767+// ReserveResult is the executor's accept/reject for a ReserveSeat.
6868+message ReserveResult {
6969+ string lease_id = 1 [(buf.validate.field).string.min_len = 1];
7070+ bool accepted = 2;
7171+ string reject_reason = 3;
7272+ // optional bid score the mill ranks accepted leases by (higher is better).
7373+ double score = 4;
7474+}
7575+7676+// Secret is a single unlocked secret, sent only inside CommitLease.
7777+message Secret {
7878+ string key = 1;
7979+ string value = 2;
8080+}
8181+8282+// CommitLease promotes a reservation to a running job and hands over the
8383+// secrets. The executor then runs the real engine with the slot it already
8484+// holds.
8585+message CommitLease {
8686+ string lease_id = 1 [(buf.validate.field).string.min_len = 1];
8787+ repeated Secret secrets = 2;
8888+}
8989+9090+// Committed acks a CommitLease.
9191+message Committed {
9292+ string lease_id = 1 [(buf.validate.field).string.min_len = 1];
9393+}
9494+9595+// ReleaseLease tells the executor to drop a reservation it never committed (the
9696+// mill picked another node, or is cleaning up).
9797+message ReleaseLease {
9898+ string lease_id = 1 [(buf.validate.field).string.min_len = 1];
9999+}
100100+101101+// CancelAttempt cancels a running attempt (user cancel / DestroyWorkflow).
102102+message CancelAttempt {
103103+ string lease_id = 1 [(buf.validate.field).string.min_len = 1];
104104+ string reason = 2;
105105+}
106106+107107+// StatusEvent relays a non-terminal status (i.e. running) the executor wrote to
108108+// its own eventstream. Terminals are authored by the mill from the attempt
109109+// result, so they are never relayed here. Carries a per-session monotonic
110110+// offset for gap-free replay across reconnects.
111111+message StatusEvent {
112112+ uint64 offset = 1;
113113+ string lease_id = 2 [(buf.validate.field).string.min_len = 1];
114114+ string status = 3;
115115+ string error = 4;
116116+ int64 exit_code = 5;
117117+ string workflow = 6;
118118+ string pipeline_aturi = 7;
119119+}
120120+121121+// LogLine relays one already-encoded models.LogLine JSON line. Carries an
122122+// offset on the same per-session sequence as StatusEvent.
123123+message LogLine {
124124+ uint64 offset = 1;
125125+ string lease_id = 2 [(buf.validate.field).string.min_len = 1];
126126+ bytes raw_json = 3;
127127+}
128128+129129+// AttemptResult is the terminal signal that wakes the mill's blocked RunStep
130130+// and resolves the lease. terminal_status is one of success/failed/timeout/
131131+// cancelled. Carries an offset on the same per-session sequence.
132132+message AttemptResult {
133133+ uint64 offset = 1;
134134+ string lease_id = 2 [(buf.validate.field).string.min_len = 1];
135135+ string terminal_status = 3;
136136+ string error = 4;
137137+ int64 exit_code = 5;
138138+}
139139+140140+// Ack tells the executor the mill has durably processed all relay entries up
141141+// to and including up_to_offset, so it may trim its buffer.
142142+message Ack {
143143+ uint64 up_to_offset = 1;
144144+}
145145+146146+message Message {
147147+ option (buf.validate.message).oneof = {
148148+ fields: [
149149+ "hello", "resume", "node_snapshot", "reserve_seat", "reserve_result",
150150+ "commit_lease", "committed", "release_lease", "cancel_attempt",
151151+ "status_event", "log_line", "attempt_result", "ack"
152152+ ],
153153+ required: true
154154+ };
155155+156156+ Hello hello = 1;
157157+ Resume resume = 2;
158158+ NodeSnapshot node_snapshot = 3;
159159+ ReserveSeat reserve_seat = 4;
160160+ ReserveResult reserve_result = 5;
161161+ CommitLease commit_lease = 6;
162162+ Committed committed = 7;
163163+ ReleaseLease release_lease = 8;
164164+ CancelAttempt cancel_attempt = 9;
165165+ StatusEvent status_event = 10;
166166+ LogLine log_line = 11;
167167+ AttemptResult attempt_result = 12;
168168+ Ack ack = 13;
169169+}
+58
spindle/mill/proto/ws.go
···11+package millproto
22+33+import (
44+ "io"
55+ "sync"
66+77+ "github.com/gorilla/websocket"
88+)
99+1010+// WSStream adapts a gorilla websocket connection to an io.ReadWriteCloser so the
1111+// length-prefixed fleet framing rides over it. Each Encode produces exactly one
1212+// binary frame; the reader reassembles the byte stream across frames.
1313+type WSStream struct {
1414+ conn *websocket.Conn
1515+1616+ rmu sync.Mutex
1717+ r io.Reader // current message reader, advanced as frames are consumed
1818+1919+ wmu sync.Mutex
2020+}
2121+2222+func NewWSStream(conn *websocket.Conn) *WSStream {
2323+ return &WSStream{conn: conn}
2424+}
2525+2626+func (s *WSStream) Read(p []byte) (int, error) {
2727+ s.rmu.Lock()
2828+ defer s.rmu.Unlock()
2929+ for {
3030+ if s.r == nil {
3131+ _, r, err := s.conn.NextReader()
3232+ if err != nil {
3333+ return 0, err
3434+ }
3535+ s.r = r
3636+ }
3737+ n, err := s.r.Read(p)
3838+ if err == io.EOF {
3939+ s.r = nil
4040+ if n > 0 {
4141+ return n, nil
4242+ }
4343+ continue
4444+ }
4545+ return n, err
4646+ }
4747+}
4848+4949+func (s *WSStream) Write(p []byte) (int, error) {
5050+ s.wmu.Lock()
5151+ defer s.wmu.Unlock()
5252+ if err := s.conn.WriteMessage(websocket.BinaryMessage, p); err != nil {
5353+ return 0, err
5454+ }
5555+ return len(p), nil
5656+}
5757+5858+func (s *WSStream) Close() error { return s.conn.Close() }