syntax = "proto3";

package workerproxy.v1;

option go_package = "github.com/sailresearchco/sail/backend/internal/sailbox/workerproxy/pb;pb";

// WorkerProxyService carries per-sailbox traffic that must reach the box's
// current worker: exec/stdio and guest file IO, plus the ingress data plane.
// Control or metadata that doesn't touch the live guest (lifecycle, list/get
// sailbox, list/get listeners) belongs on the HTTP sailbox-API, not here.
service WorkerProxyService {
  // StreamSailboxExec submits one exec and streams its output live. Admission —
  // a best-effort Voyages-evidence insert and a runnable check on the box —
  // happens before the first frame, so submission failures surface as gRPC
  // statuses; everything after surfaces as frames. The command runs in the guest
  // VM, detached from this connection: dropping the connection never kills it.
  // Any pod can serve the stream; it relays the guest's output buffer, which
  // lives in the VM. The guest owns exec identity and dedups by idempotency_key,
  // even replaying an already-finished exec. To resume after a dropped
  // connection, the client re-calls with the same idempotency_key and the
  // per-stream resume seqs it last saw; the guest replays only the unseen tail. A
  // stream that ends without an Exit frame just means this connection stopped;
  // the client finishes by calling WaitSailboxExec, which reattaches to the guest
  // for the result.
  rpc StreamSailboxExec(StreamSailboxExecRequest) returns (stream StreamSailboxExecResponse);
  // WriteSailboxExecStdin appends bytes to a running exec's stdin. It is
  // deliberately out-of-band from the output stream, so any pod can serve it by
  // forwarding to the exec's recorded worker. Writes carry a byte offset so
  // retries are idempotent: the guest trims already-accepted overlap and
  // rejects gaps. The guest's bounded stdin buffer may accept a write
  // partially; `accepted_through` tells the caller where to resume.
  rpc WriteSailboxExecStdin(WriteSailboxExecStdinRequest) returns (WriteSailboxExecStdinResponse);
  rpc WaitSailboxExec(WaitSailboxExecRequest) returns (WaitSailboxExecResponse);
  rpc CancelSailboxExec(CancelSailboxExecRequest) returns (CancelSailboxExecResponse);
  // ResizeSailboxExec sets a pty exec's window. Out-of-band like
  // WriteSailboxExecStdin / CancelSailboxExec, so any pod can serve it by
  // forwarding to the exec's recorded worker. A no-op for a non-pty exec.
  rpc ResizeSailboxExec(ResizeSailboxExecRequest) returns (ResizeSailboxExecResponse);
  // ReportSailboxExecCompletion is the worker's backstop durable-write path for
  // a finished exec. A streamed exec's row is written by the attached workerproxy
  // straight from the guest's Exit frame; this covers execs nobody streamed (and
  // write failures). The host saild harvests the terminal result (exit code,
  // captured output tail, truncation) from the VM and calls this so the
  // workerproxy (which owns the sailbox DB pool the worker has no access to)
  // persists the row. Internal-auth only; idempotent against the on-Exit write (a
  // row already finalized is left untouched), so a result is durable whether or
  // not anyone streamed or waited on it.
  rpc ReportSailboxExecCompletion(ReportSailboxExecCompletionRequest) returns (ReportSailboxExecCompletionResponse);
  rpc ReadSailboxFile(ReadSailboxFileRequest) returns (stream ReadSailboxFileResponse);
  rpc WriteSailboxFile(stream WriteSailboxFileRequest) returns (WriteSailboxFileResponse);
  rpc GetListener(GetListenerRequest) returns (GetListenerResponse);
  rpc ListListeners(ListListenersRequest) returns (ListListenersResponse);
  // OpenIngress resolves one inbound client TCP connection and prepares its
  // resumable ingress stream, returning the tcp-spooler node that owns it. The
  // public sailbox-tcp-relay edge calls it for each freshly accepted connection:
  // the workerproxy resolves the public (host, port) to a sailbox listener,
  // enforces the listener's allowlist, wakes the box, triggers the in-guest sidecar to
  // dial the node, asks that node to prepare the parked stream, and returns the
  // node's address. The relay then carries the bytes directly against the node
  // (TunnelClientTCP), so the workerproxy is never in the byte path and its pods
  // roll without disturbing live sessions. Gated by the scoped relay bearer.
  rpc OpenIngress(OpenIngressRequest) returns (OpenIngressResponse);
  // PrepareIngressStream creates a parked ingress stream on the tcp-spooler node
  // that owns it, ready for a relay to resume. The workerproxy calls it from
  // OpenIngress once it has resolved the target and triggered the sidecar.
  // Naming an arbitrary sailbox + guest port is privileged, so the node requires
  // the cluster-internal secret, which only the workerproxy holds and presents
  // only after resolving the listener and enforcing the allowlist.
  rpc PrepareIngressStream(PrepareIngressStreamRequest) returns (PrepareIngressStreamResponse);
  // TunnelClientTCP carries one raw client TCP connection between the
  // sailbox-tcp-relay edge and the tcp-spooler node that owns its ingress
  // stream. The first frame is always resume: the relay binds the parked stream
  // OpenIngress prepared, or re-binds it after a relay handoff. Subsequent
  // frames carry payload bytes or a write_eof half-close in either direction,
  // and a detach parks the stream for a successor relay. The relay reaches the
  // node directly, gated by the scoped relay bearer; resume only binds a stream
  // the workerproxy already resolved and allowlisted, so a leaked relay bearer
  // cannot open one to an arbitrary box.
  rpc TunnelClientTCP(stream TunnelClientTCPRequest) returns (stream TunnelClientTCPResponse);
}

enum SailboxExecStatus {
  // PENDING (1), INTERRUPTED_RETRYABLE/UNSAFE (6, 7), and CANCELED (9) are retired: the guest owns
  // exec state, so this RPC surface never carries those wire statuses — a wait or stream Exit
  // carries the guest's own terminal result, or a synthesized worker_lost.
  reserved 1, 6, 7, 9;
  reserved "SAILBOX_EXEC_STATUS_PENDING", "SAILBOX_EXEC_STATUS_INTERRUPTED_RETRYABLE",
      "SAILBOX_EXEC_STATUS_INTERRUPTED_UNSAFE_TO_RETRY", "SAILBOX_EXEC_STATUS_CANCELED";
  SAILBOX_EXEC_STATUS_UNSPECIFIED = 0;
  SAILBOX_EXEC_STATUS_RUNNING = 2;
  SAILBOX_EXEC_STATUS_SUCCEEDED = 3;
  SAILBOX_EXEC_STATUS_FAILED = 4;
  SAILBOX_EXEC_STATUS_TIMED_OUT = 5;
  SAILBOX_EXEC_STATUS_WORKER_LOST = 8;
}

// SailboxExecStream selects which standard stream a chunk belongs to.
enum SailboxExecStream {
  SAILBOX_EXEC_STREAM_UNSPECIFIED = 0;
  SAILBOX_EXEC_STREAM_STDOUT = 1;
  SAILBOX_EXEC_STREAM_STDERR = 2;
}

enum ListenerRouteStatus {
  LISTENER_ROUTE_STATUS_UNSPECIFIED = 0;
  LISTENER_ROUTE_STATUS_PENDING = 1;
  LISTENER_ROUTE_STATUS_ACTIVE = 2;
  LISTENER_ROUTE_STATUS_RESTORING = 3;
  LISTENER_ROUTE_STATUS_UNAVAILABLE = 4;
}

message StreamSailboxExecRequest {
  string sailbox_id = 1;
  repeated string argv = 2;
  uint32 timeout_seconds = 3;
  string idempotency_key = 4;
  // open_stdin gives the command a stdin pipe fed by WriteSailboxExecStdin,
  // held open until an eof write. When false the command reads instant EOF
  // (/dev/null semantics), so commands that read stdin never hang by default.
  bool open_stdin = 5;
  // stdout_resume_seq / stderr_resume_seq resume a dropped stream: on a
  // reconnect (same idempotency_key) the client sets each to the highest seq it
  // has already consumed for that stream, and the guest replays only output
  // past it. Zero on a fresh submission.
  int64 stdout_resume_seq = 6;
  int64 stderr_resume_seq = 7;
  // pty runs the command under a pseudo-terminal (interactive shell / tty
  // programs). stdout and stderr merge onto the stdout stream and the command
  // sees a real tty; control bytes written via WriteSailboxExecStdin become
  // signals, and ResizeSailboxExec adjusts the window. Implies open_stdin.
  bool pty = 8;
  // term_cols / term_rows seed the initial pty window (default 80x24 when
  // zero). term seeds $TERM. All three are ignored unless pty is set.
  uint32 term_cols = 9;
  uint32 term_rows = 10;
  string term = 11;
}

message StreamSailboxExecResponse {
  oneof frame {
    // started is always the first frame; it carries the durable
    // exec_request_id used by WaitSailboxExec / CancelSailboxExec /
    // WriteSailboxExecStdin.
    SailboxExecStarted started = 1;
    SailboxExecChunk chunk = 2;
    // exit is terminal. Its absence when the stream ends means the result is
    // not known on this stream; call WaitSailboxExec for it.
    SailboxExecExit exit = 3;
  }
}

message SailboxExecStarted {
  string exec_request_id = 1;
}

// SailboxExecChunk is one slice of live output. `seq` is the guest's monotonic
// per-stream sequence number; the client remembers the highest it has seen so a
// reconnect can resume past it (StreamSailboxExecRequest.{stdout,stderr}_resume_seq).
message SailboxExecChunk {
  SailboxExecStream stream = 1;
  bytes data = 2;
  int64 seq = 3;
}

// SailboxExecExit reports the exec's terminal result. Truncation flags refer to
// the guest's bounded output rings (output past the cap was dropped oldest-first).
message SailboxExecExit {
  SailboxExecStatus status = 1;
  int32 return_code = 2;
  bool timed_out = 3;
  bool stdout_truncated = 4;
  bool stderr_truncated = 5;
  // error_message explains a non-succeeded finalization (e.g. an interruption
  // cause) for a status that has no real exit code of its own.
  string error_message = 6;
  // stdout_seq / stderr_seq are the highest chunk seq the command produced on
  // each stream. A client that received every chunk up to these has the complete
  // live output; one that fell behind (e.g. a migration replay that did not
  // deliver the tail) is short and must fall back to wait()'s authoritative
  // result instead of returning its local buffer with the success code.
  int64 stdout_seq = 7;
  int64 stderr_seq = 8;
}

message WriteSailboxExecStdinRequest {
  string sailbox_id = 1;
  string exec_request_id = 2;
  // offset is the absolute stdin byte offset of data[0]. Must not exceed the
  // guest's accepted high-water mark (no gaps); overlap below it is trimmed.
  int64 offset = 3;
  bytes data = 4;
  // eof closes the command's stdin once every buffered byte drains. It
  // latches only when this write's bytes are fully accepted: a
  // partially-accepted final write keeps the pipe open until the caller
  // resends the remainder.
  bool eof = 5;
}

message WriteSailboxExecStdinResponse {
  // accepted_through is the stdin byte offset the guest has now buffered
  // through. Less than offset+len(data) means the bounded buffer filled;
  // resume from here.
  int64 accepted_through = 1;
}

message WaitSailboxExecRequest {
  string sailbox_id = 1;
  string exec_request_id = 2;
}

message WaitSailboxExecResponse {
  string exec_request_id = 1;
  SailboxExecStatus status = 2;
  string stdout = 3;
  string stderr = 4;
  int32 return_code = 5;
  bool stdout_truncated = 6;
  bool stderr_truncated = 7;
  bool timed_out = 8;
}

// CancelSailboxExecRequest signals the currently-running guest command for an
// exec_request_id. Setting `force=true` escalates to SIGKILL; the default
// SIGINT lets user-installed shell traps fire so the natural exit code
// (typically 128 + signal) is reported via WaitSailboxExec. Idempotent:
// canceling an exec that has already completed, or one that the worker no
// longer knows about, returns successfully.
message CancelSailboxExecRequest {
  string sailbox_id = 1;
  string exec_request_id = 2;
  bool force = 3;
}

message CancelSailboxExecResponse {}

// ResizeSailboxExecRequest sets the window of a pty-backed exec. Out-of-band
// from the output stream; the latest window wins and nothing is persisted.
// Idempotent and a no-op for a non-pty or unknown exec.
message ResizeSailboxExecRequest {
  string sailbox_id = 1;
  string exec_request_id = 2;
  uint32 cols = 3;
  uint32 rows = 4;
}

message ResizeSailboxExecResponse {}

// ReportSailboxExecCompletionRequest carries a finished command's terminal
// result, harvested from the guest by its host worker. return_code and
// timed_out determine the persisted status; a canceled command reports its
// natural exit code (a SIGINT'd program exits 130, or whatever its trap
// returns), so cancellation needs no dedicated field. stdout/stderr are the
// captured output tails (already valid UTF-8, since the guest sanitizes at capture).
message ReportSailboxExecCompletionRequest {
  string sailbox_id = 1;
  string exec_request_id = 2;
  int32 return_code = 3;
  bool timed_out = 4;
  string stdout = 6;
  string stderr = 7;
  bool stdout_truncated = 8;
  bool stderr_truncated = 9;
}

message ReportSailboxExecCompletionResponse {}

message ReadSailboxFileRequest {
  string sailbox_id = 1;
  string path = 2;
}

message ReadSailboxFileResponse {
  bytes data = 1;
  uint64 size_bytes = 2;
  uint32 mode = 3;
}

message WriteSailboxFileRequest {
  string sailbox_id = 1;
  string path = 2;
  bytes data = 3;
  bool create_parents = 4;
  optional uint32 mode = 5;
}

message WriteSailboxFileResponse {
  uint64 size_bytes = 1;
}

message Listener {
  uint32 guest_port = 1;
  // Routable HTTPS URL for "http" listeners; empty for "tcp".
  string public_url = 2;
  string protocol = 3;
  ListenerRouteStatus route_status = 4;
  // Public TCP dial target for "tcp" listeners: the relay host (e.g.
  // t1.sail.box) and the listener's allocated public port. Empty/zero for
  // "http" listeners and for "tcp" listeners in environments where TCP
  // ingress is not configured.
  string public_host = 5;
  uint32 public_port = 6;
}

message GetListenerRequest {
  string sailbox_id = 1;
  uint32 guest_port = 2;
}

message GetListenerResponse {
  Listener listener = 1;
}

message ListListenersRequest {
  string sailbox_id = 1;
}

message ListListenersResponse {
  repeated Listener listeners = 1;
}

message TunnelClientTCPRequest {
  oneof body {
    // resume is always the first frame: the relay binds the parked ingress
    // stream OpenIngress prepared (client_delivered_offset 0), or re-binds it
    // after a relay handoff. It is sent directly to the tcp-spooler that owns
    // the stream, which delivers the guest->client bytes buffered while the
    // stream was parked and continues the session.
    TunnelClientTCPResume resume = 5;
    bytes data = 2;
    TunnelClientTCPWriteEOF write_eof = 3;
    // detach hands this stream's client-facing leg off to a successor relay
    // process: the tcp-spooler parks the resumable ingress stream (holding the
    // guest leg and replay buffers) instead of tearing it down, and the
    // successor re-binds it with a resume frame on a fresh stream.
    TunnelClientTCPDetach detach = 4;
  }
}

message TunnelClientTCPResponse {
  oneof body {
    bytes data = 1;
    TunnelClientTCPWriteEOF write_eof = 2;
    // drain asks the relay to hand this stream to the tcp-spooler's successor
    // during a graceful node upgrade: the relay detaches at a frame boundary (so
    // the node parks the stream at an exact offset) and resumes against the same
    // node address, which the successor inherits. Without it a node upgrade would
    // sever active ingress legs.
    TunnelClientTCPDrain drain = 3;
    // resume_ack is the node's first frame once it has bound a resume onto the
    // parked stream. The relay withholds the client->guest direction until it
    // arrives: a resume that raced onto a quiescing predecessor is rejected
    // instead, and the relay has no client->guest replay buffer, so it must not
    // forward client bytes into a stream that might not have been accepted.
    TunnelClientTCPResumeAck resume_ack = 4;
  }
}

message TunnelClientTCPWriteEOF {}

message TunnelClientTCPDetach {}

message TunnelClientTCPDrain {}

message TunnelClientTCPResumeAck {}

message TunnelClientTCPResume {
  // stream_id names the parked ingress stream to re-bind (the relay-generated id
  // it supplied to OpenIngress).
  string stream_id = 1;
  // client_delivered_offset is the guest->client byte offset the client has
  // already received (everything the predecessor relay wrote to the client
  // socket). It must equal the byte count the tcp-spooler delivered to the
  // predecessor leg; a mismatch rejects the resume. Parking waits out any
  // in-flight client write first, so the offsets agree whenever the handoff
  // relays the count faithfully.
  uint64 client_delivered_offset = 2;
}

message OpenIngressRequest {
  // Public host the external connection arrived on (the relay derives it from
  // the accepted socket's local address); pairs with public_port as the
  // (host, port) routing key the workerproxy resolves to a sailbox listener.
  string host = 1;
  // Public TCP port the external connection arrived on.
  uint32 public_port = 2;
  // Source IP of the external client (the relay's accepted RemoteAddr), used
  // for CIDR allowlist enforcement and audit.
  string client_ip = 3;
  // stream_id is a relay-generated id naming the resulting ingress stream so the
  // relay can resume it (initially, and across a relay process handoff).
  string stream_id = 4;
  // Optional authenticated source sailbox identity for sailbox-origin traffic.
  // The public TCP relay leaves these empty; workerproxy validates the auth
  // token before using the source app for ingress allowlist checks.
  string source_sailbox_id = 5;
  string source_auth_token = 6;
}

message OpenIngressResponse {
  // owner_addr is the tcp-spooler node that owns the prepared ingress stream;
  // the relay resumes the stream directly against it (TunnelClientTCP).
  string owner_addr = 1;
  // sailbox_id and guest_port name the resolved listener target. The relay
  // never needs them to carry bytes (it routes purely by owner_addr +
  // stream_id), but it records them in its per-connection close summary so a
  // relay-edge session log identifies the box and guest port it served without
  // a join back to the workerproxy. Both are advisory: an older workerproxy
  // leaves them empty and the relay simply logs no sailbox identity.
  string sailbox_id = 2;
  uint32 guest_port = 3;
}

message PrepareIngressStreamRequest {
  // stream_id is the relay-generated id the workerproxy passes through verbatim;
  // the relay resumes the stream by this id.
  string stream_id = 1;
  // sailbox_id is the resolved owner of the listener.
  string sailbox_id = 2;
  // guest_port is the in-guest port the listener forwards to.
  uint32 guest_port = 3;
  // client_ip is the source IP of the external client, for audit; the
  // workerproxy has already enforced the listener's allowlist.
  string client_ip = 4;
}

message PrepareIngressStreamResponse {}
