///|
/// How a mid-turn steer reaches the model.
///
/// The wire spells these `"prompt"` and `"command"`. `Notice` is deliberately
/// absent: the engine synthesizes notices for itself (a background job
/// finishing), and reports them back as `steer_applied` with `kind: "notice"`,
/// but no controller may send one — `decode` has always rejected an unknown
/// kind, and a notice is not a thing a client has to say.
pub(all) enum SteerKind {
Prompt
Command
} derive(Eq, Debug)
///|
/// One command a controller writes to `openseek serve`'s stdin.
///
/// The other half of the serve protocol: `Event` is what the engine reports,
/// this is what it is told. Both directions used to be anonymous JSON at each
/// end — the events for ~60 call sites and three decoders, these for one
/// decoder and two encoders that had already drifted apart:
///
/// - the TUI sent `steer` with a `kind`; the desktop sent it without one, and
/// worked only because `parse` falls back to `Prompt`. Neither end wrote that
/// rule down.
/// - the desktop had no `goal` at all, so the same protocol meant different
/// things to its two clients, and nothing said whether that was a decision.
///
/// One type, one encoder, one decoder — the same shape the event direction now
/// has.
pub(all) enum Command {
/// `submission_id` is the submitting controller's tag for this prompt. The
/// engine records it on the turn's durable `User` item, so a delivery can
/// later be reconciled by exact id rather than by matching text. Controllers
/// that do not reconcile deliveries (the TUI) send none.
Prompt(text~ : String, submission_id~ : String?)
Steer(kind~ : SteerKind, text~ : String)
Compact
Cancel
/// `auto` arms autonomous continuation toward the goal. No in-repo client
/// sends it — the TUI's `/goal` always sets a manual goal — but `serve` is a
/// protocol, not a private channel, and an external controller drives this.
GoalSet(text~ : String, auto~ : Bool)
GoalClear
/// The answer to an `approval_requested` event, addressed by the `id` that
/// event carried. The only command that replies to the engine rather than
/// instructing it, and the only one a controller MUST send: until it
/// arrives, the asking tool is blocked.
///
/// `allow` is deliberately a Bool and not a decision vocabulary. There is
/// exactly one grant to express — permission for the single action that was
/// asked about — and a richer answer ("allow for this session", "allow
/// commands like this one") would have to say what the grant's equivalence
/// class is, which nothing on either end of this wire can currently define.
ApprovalDecision(id~ : String, allow~ : Bool)
} derive(Eq, Debug)
///|
/// The command as the line a controller writes.
pub fn Command::to_json(self : Command) -> Json {
match self {
// Like `auto` below, the tag is written only when present: omission is
// what an untagged prompt has always looked like on this wire, so the
// common line stays byte for byte what every controller has written.
Prompt(text~, submission_id~) =>
if submission_id is Some(id) {
{ "command": "prompt", "text": text, "submission_id": id }
} else {
{ "command": "prompt", "text": text }
}
Steer(kind~, text~) =>
{
"command": "steer",
"kind": match kind {
Prompt => "prompt"
Command => "command"
},
"text": text,
}
Compact => { "command": "compact" }
Cancel => { "command": "cancel" }
// `auto` is written only when set: an engine older than the flag ignores an
// unknown key, but the field's absence is also what `false` has always
// looked like on this wire, so omitting it keeps the common line unchanged.
GoalSet(text~, auto~) =>
if auto {
{ "command": "goal", "action": "set", "text": text, "auto": true }
} else {
{ "command": "goal", "action": "set", "text": text }
}
GoalClear => { "command": "goal", "action": "clear" }
ApprovalDecision(id~, allow~) =>
{ "command": "approval", "id": id, "allow": allow }
}
}
///|
/// Read one stdin line back into a `Command`.
///
/// `Err` carries the text the engine reports to its controller as a
/// `command_error` event, so these strings are themselves wire contract, not
/// diagnostics — they are what a controller reads to learn what it got wrong.
///
/// It reports a line this protocol cannot read, and nothing else. Whether a
/// readable command is *acceptable* is the engine's to say: a blank goal is
/// well-formed and refused, and `serve` refuses it. Were that check here,
/// `GoalSet(text=" ")` would be a value this type holds and `to_json` writes
/// but `parse` rejects — breaking the round-trip below for a value the type
/// itself offers.
///
/// Unlike `parse` for events, this returns a `Result`: an unreadable event is
/// a line to ignore, but an unreadable command is a request that will never be
/// answered, and silence is the one reply a controller cannot act on.
///
/// ## When a field may be absent
///
/// The same rule `parse` states for events, and both of its cases apply here —
/// one field each, verified against `git log -S` for the field versus its
/// command's introducing commit:
///
/// 1. **Added after its command existed.** `steer`'s `kind` arrived in
/// 9b2f6c43; `steer` itself in 17562cc1. An engine from between them is sent
/// a `kind` it ignores, and a controller older than the field sends none —
/// so absent means `Prompt`, which is what every controller meant before
/// there was a choice. The desktop was still sending exactly that.
/// `prompt`'s `submission_id` is the same case: it arrived long after
/// `prompt` itself, an engine older than the field ignores the key, and a
/// controller that does not tag its submissions sends none — absent means
/// untagged, which is what every prompt was before there was a tag.
/// 2. **Optional since the command shipped.** `goal`'s `auto` arrived *with*
/// `goal` (c8cc6ad7), so case (1) does not cover it — but the engine has
/// read `auto` as absent-means-false from the first line it ever decoded.
/// It is a flag with a default, not a value that can go missing.
///
/// The distinction matters because only (1) is about talking to an old
/// binary. A field that is merely unread — `text` on a command nobody
/// inspects — is still required: its absence means the line is not what it
/// claims.
pub fn Command::parse(line : Json) -> Result[Command, String] {
match line {
{ "command": "prompt", "text": String(text), .. } =>
match line {
{ "submission_id": String(id), .. } =>
Ok(Prompt(text~, submission_id=Some(id)))
{ "submission_id": _, .. } =>
Err("expected a string \"submission_id\" field")
// An untagged prompt: see the absence rule above.
_ => Ok(Prompt(text~, submission_id=None))
}
{ "command": "steer", .. } => parse_steer(line)
{ "command": "prompt", .. } => Err("expected a string \"text\" field")
{ "command": "compact", .. } => Ok(Compact)
{ "command": "cancel", .. } => Ok(Cancel)
// A blank goal decodes: `{"action":"set","text":" "}` is a well-formed
// command, and refusing it is the engine's policy, not the wire's shape.
// Keeping the check here would make `GoalSet(text=" ")` a value this type
// can hold, `to_json` can write, and `parse` then rejects — a round-trip
// the type advertises and would not have. `serve` rejects it, in the same
// words, where the policy lives.
{ "command": "goal", "action": "set", "text": String(text), .. } =>
Ok(GoalSet(text~, auto=line is { "auto": True, .. }))
{ "command": "goal", "action": "clear", .. } => Ok(GoalClear)
{ "command": "goal", .. } =>
Err(
"expected {\"action\": \"set\", \"text\": ...} or {\"action\": \"clear\"}",
)
// Both fields are required. Neither has a defensible default: an approval
// with no `id` answers no particular question, and one with no `allow` is
// a decision that did not decide — silently reading either as absent would
// settle a permission prompt on a guess.
{ "command": "approval", "id": String(id), "allow": True, .. } =>
Ok(ApprovalDecision(id~, allow=true))
{ "command": "approval", "id": String(id), "allow": False, .. } =>
Ok(ApprovalDecision(id~, allow=false))
{ "command": "approval", "id": String(_), .. } =>
Err("expected a boolean \"allow\" field")
{ "command": "approval", .. } => Err("expected a string \"id\" field")
{ "command": String(other), .. } => Err("unknown command: \{other}")
_ => Err("expected a {\"command\": ...} object")
}
}
///|
fn parse_steer(line : Json) -> Result[Command, String] {
// `text` is required whatever the kind says, so it is checked once here
// rather than restated in three arms, where the precedence between "no text"
// and "bad kind" was emergent from their order.
guard line is { "text": String(text), .. } else {
return Err("expected a string \"text\" field")
}
match line {
{ "kind": String("prompt"), .. } => Ok(Steer(kind=Prompt, text~))
{ "kind": String("command"), .. } => Ok(Steer(kind=Command, text~))
{ "kind": String(other), .. } => Err("unknown steer kind: \{other}")
{ "kind": _, .. } => Err("expected a string \"kind\" field")
// A steer with no kind at all is a prompt steer: see the rule above.
_ => Ok(Steer(kind=Prompt, text~))
}
}
///|
pub extend Command with Debug::{to_repr}
///|
pub extend Command with Eq::{equal, not_equal}
///|
pub extend SteerKind with Debug::{to_repr}
///|
pub extend SteerKind with Eq::{equal, not_equal}