///|
fn last_seen_id_to_json(id : Int64?) -> Json {
match id {
Some(n) => Json::number(n.to_double(), repr=n.to_string())
None => Json::null()
}
}
///|
fn id_to_json(id : Int64) -> Json {
Json::number(id.to_double(), repr=id.to_string())
}
///|
fn parse_optional_int64(
m : Map[String, Json],
key : String,
path : @json.JsonPath,
) -> Int64? raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => None
Some(Number(n, ..)) => Some(n.to_int64())
Some(String(s)) =>
Some(
@string.parse_int64(s) catch {
_ => raise @json.JsonDecodeError((path, "invalid int64 for '\{key}'"))
},
)
_ => raise @json.JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
fn parse_optional_bool(
m : Map[String, Json],
key : String,
path : @json.JsonPath,
) -> Bool? raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => None
Some(True) => Some(true)
Some(False) => Some(false)
_ => raise @json.JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
/// Parse an optional integer field, returning `default` when the key
/// is absent or explicitly null. A peer built before the field
/// existed decodes to the default — this is what makes Register /
/// Connect `proto_version` fully backwards-compatible with v0 peers.
fn parse_int_or_default(
m : Map[String, Json],
key : String,
default~ : Int,
path : @json.JsonPath,
) -> Int raise @json.JsonDecodeError {
match m.get(key) {
None | Some(Null) => default
Some(Number(n, ..)) => n.to_int()
Some(String(s)) =>
@string.parse_int(s) catch {
_ => raise @json.JsonDecodeError((path, "invalid int for '\{key}'"))
}
_ => raise @json.JsonDecodeError((path, "invalid type for '\{key}'"))
}
}
///|
pub impl ToJson for SysMessage with to_json(self) {
match self {
Register(task_uuid~, hostname~, cwd~, proto_version~) =>
{
"ch": "sys",
"type": "register",
"task_uuid": task_uuid.to_string(),
"hostname": hostname,
"cwd": cwd,
"proto_version": proto_version,
}
Registered(task_uuid~) =>
{ "ch": "sys", "type": "registered", "task_uuid": task_uuid.to_string() }
RegisterRejected(reason~) => {
// `min_version` rides alongside the reason string so the client
// can tell the operator which version to upgrade to. Only the
// ProtoVersionTooOld variant carries it.
let base : Map[String, Json] = {
"ch": "sys",
"type": "register_rejected",
"reason": reason.to_wire(),
}
match reason {
ProtoVersionTooOld(min_version~) =>
base["min_version"] = Json::number(min_version.to_double())
_ => ()
}
Json::object(base)
}
TaskExited(exit_code~) =>
{ "ch": "sys", "type": "task_exited", "exit_code": exit_code }
Connect(task_uuid~, last_seen_id~, proto_version~) =>
{
"ch": "sys",
"type": "connect",
"task_uuid": task_uuid.to_string(),
"last_seen_id": last_seen_id_to_json(last_seen_id),
"proto_version": proto_version,
}
Connected => { "ch": "sys", "type": "connected" }
TaskOnline => { "ch": "sys", "type": "task_online" }
NewClient(client_id~, last_seen_id~) =>
{
"ch": "sys",
"type": "new_client",
"client_id": client_id,
"last_seen_id": last_seen_id_to_json(last_seen_id),
}
ClientDisconnected(client_id~) =>
{ "ch": "sys", "type": "client_disconnected", "client_id": client_id }
Error(code) => {
let base : Map[String, Json] = {
"ch": "sys",
"type": "error",
"code": code.to_wire(),
}
match code {
ProtoVersionTooOld(min_version~) =>
base["min_version"] = Json::number(min_version.to_double())
_ => ()
}
Json::object(base)
}
AuthError(message~) =>
{ "ch": "sys", "type": "auth_error", "message": message }
}
}
///|
pub impl @json.FromJson for SysMessage with from_json(json, path) {
guard json is Object(fields) else {
raise @json.JsonDecodeError((path, "expected JSON object"))
}
let t = match fields.get("type") {
Some(String(s)) => s
_ => raise @json.JsonDecodeError((path, "missing field 'type'"))
}
match t {
"register" => {
guard fields.get("task_uuid") is Some(String(uuid_str)) else {
raise @json.JsonDecodeError((path, "missing field 'task_uuid'"))
}
let task_uuid = @uuid.from_hex(uuid_str) catch {
_ =>
raise @json.JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
}
let hostname = match fields.get("hostname") {
Some(String(s)) => s
_ => ""
}
let cwd = match fields.get("cwd") {
Some(String(s)) => s
_ => ""
}
// Missing field = v0 (pre-versioning peer).
let proto_version = parse_int_or_default(
fields,
"proto_version",
default=0,
path,
)
Register(task_uuid~, hostname~, cwd~, proto_version~)
}
"registered" => {
guard fields.get("task_uuid") is Some(String(uuid_str)) else {
raise @json.JsonDecodeError((path, "missing field 'task_uuid'"))
}
let task_uuid = @uuid.from_hex(uuid_str) catch {
_ =>
raise @json.JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
}
Registered(task_uuid~)
}
"register_rejected" => {
guard fields.get("reason") is Some(String(reason_str)) else {
raise @json.JsonDecodeError((path, "missing field 'reason'"))
}
let reason : RegisterRejectReason = match reason_str {
"uuid_already_claimed_this_session" => UuidAlreadyClaimedThisSession
"invalid_uuid" => InvalidUuid
"proto_version_too_old" => {
let min_version = parse_int_or_default(
fields,
"min_version",
default=0,
path,
)
ProtoVersionTooOld(min_version~)
}
_ =>
raise @json.JsonDecodeError(
(path, "unknown register_rejected reason: \{reason_str}"),
)
}
RegisterRejected(reason~)
}
"task_exited" => {
guard fields.get("exit_code") is Some(Number(n, ..)) else {
raise @json.JsonDecodeError((path, "missing field 'exit_code'"))
}
TaskExited(exit_code=n.to_int())
}
"connect" => {
guard fields.get("task_uuid") is Some(String(uuid_str)) else {
raise @json.JsonDecodeError((path, "missing field 'task_uuid'"))
}
let task_uuid = @uuid.from_hex(uuid_str) catch {
_ =>
raise @json.JsonDecodeError((path, "invalid task_uuid: \{uuid_str}"))
}
let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
let proto_version = parse_int_or_default(
fields,
"proto_version",
default=0,
path,
)
Connect(task_uuid~, last_seen_id~, proto_version~)
}
"connected" => Connected
"task_online" => TaskOnline
"new_client" => {
guard fields.get("client_id") is Some(String(id)) else {
raise @json.JsonDecodeError((path, "missing field 'client_id'"))
}
let last_seen_id = parse_optional_int64(fields, "last_seen_id", path)
NewClient(client_id=id, last_seen_id~)
}
"client_disconnected" => {
guard fields.get("client_id") is Some(String(id)) else {
raise @json.JsonDecodeError((path, "missing field 'client_id'"))
}
ClientDisconnected(client_id=id)
}
"error" => {
guard fields.get("code") is Some(String(code_str)) else {
raise @json.JsonDecodeError((path, "missing field 'code'"))
}
let code : ErrorCode = match code_str {
"server_not_found" => TaskNotFound
"server_offline" => TaskOffline
"internal_error" => InternalError
"proto_version_too_old" => {
let min_version = parse_int_or_default(
fields,
"min_version",
default=0,
path,
)
ProtoVersionTooOld(min_version~)
}
_ =>
raise @json.JsonDecodeError((path, "unknown error code: \{code_str}"))
}
Error(code)
}
"auth_error" => {
guard fields.get("message") is Some(String(msg)) else {
raise @json.JsonDecodeError((path, "missing field 'message'"))
}
AuthError(message=msg)
}
_ => raise @json.JsonDecodeError((path, "unknown sys message type: \{t}"))
}
}
///|
pub impl ToJson for TermMessage with to_json(self) {
match self {
Data(client_id~, id~, created~, replay~, data~) => {
let obj : Map[String, Json] = {
"ch": "t",
"type": "data",
"client_id": client_id,
"id": id_to_json(id),
"data": data,
}
// Omit when absent so old decoders still see a compact frame and
// only new decoders observe the field.
if created is Some(ts) {
obj["created"] = id_to_json(ts)
}
if replay is Some(b) {
obj["replay"] = b.to_json()
}
Json::object(obj)
}
Exited(client_id~, id~) =>
{
"ch": "t",
"type": "exited",
"client_id": client_id,
"id": id_to_json(id),
}
}
}
///|
pub impl @json.FromJson for TermMessage with from_json(json, path) {
guard json is Object(fields) else {
raise @json.JsonDecodeError((path, "expected JSON object"))
}
let t = match fields.get("type") {
Some(String(s)) => s
_ => raise @json.JsonDecodeError((path, "missing field 'type'"))
}
match t {
"data" => {
let client_id = match fields.get("client_id") {
Some(String(s)) => s
_ => ""
}
let id = parse_optional_int64(fields, "id", path).unwrap_or(0L)
let created = parse_optional_int64(fields, "created", path)
let replay = parse_optional_bool(fields, "replay", path)
guard fields.get("data") is Some(String(data)) else {
raise @json.JsonDecodeError((path, "missing field 'data'"))
}
Data(client_id~, id~, created~, replay~, data~)
}
"exited" => {
let client_id = match fields.get("client_id") {
Some(String(s)) => s
_ => ""
}
let id = parse_optional_int64(fields, "id", path).unwrap_or(0L)
Exited(client_id~, id~)
}
_ => raise @json.JsonDecodeError((path, "unknown term message type: \{t}"))
}
}
///|
pub impl ToJson for Message with to_json(self) {
match self {
Sys(msg) => msg.to_json()
Fs(msg) => msg.to_json()
Term(msg) => msg.to_json()
Codex(client_id~, data~) =>
{ "ch": "codex", "client_id": client_id, "data": data }
}
}
///|
pub impl @json.FromJson for Message with from_json(json, path) {
guard json is Object(fields) else {
raise @json.JsonDecodeError((path, "expected JSON object"))
}
let ch = match fields.get("ch") {
Some(String(c)) => c
_ => "sys"
}
match ch {
"sys" => Sys(@json.from_json(json, path~))
"t" => Term(@json.from_json(json, path~))
"fs" => Fs(@json.from_json(json, path~))
"codex" => {
let client_id = match fields.get("client_id") {
Some(String(s)) => s
_ => ""
}
guard fields.get("data") is Some(String(data)) else {
raise @json.JsonDecodeError((path, "missing field 'data'"))
}
Codex(client_id~, data~)
}
_ => raise @json.JsonDecodeError((path, "unknown channel: \{ch}"))
}
}