Skip to main content

onlyne_client/ops/
init.rs

1use anyhow::{Result, anyhow};
2use onlyne_config::Spec;
3use onlyne_config::layout::{RoleWorkspace, ServerRoot, detect_legacy};
4use onlyne_net::KeyPair;
5use std::path::{Path, PathBuf};
6
7/// the placement key `init` seeds (as a comment) among the top-level keys of a
8/// fresh workspace config: a commented key above `[server]` uncomments into
9/// the table it belongs to, and the parse never sees a value the template
10/// invented. The value range and the precedence are the ones `onlyne-config`'s
11/// `ClientConfig::placement` documents.
12///
13/// Placement is the machine's half of the pair; the drive is the runtime's and
14/// lives in the server's `spec.toml` (`[client.runtime]`), which is why no
15/// drive is seeded here.
16const PLACEMENT_COMMENTS: &str = "\
17# Where this machine displays the role's runtime: orca | zellij |
18# headless | external. An absent value probes orca, zellij in that order
19# and falls back to headless; a nonempty ONLYNE_BACKEND wins over this key.
20# placement = \"headless\"
21";
22
23/// the `[acp]` table `init` seeds (as a comment) at the foot of a fresh
24/// workspace config, where an uncommented table header opens a table of its
25/// own. The four keys are the ones `onlyne-config`'s `AcpSection` reads.
26const ACP_COMMENTS: &str = "\
27# ACP options, read only when the role's `[client.runtime] drive` is `acp`.
28# `mode`, `model`,
29# and `reasoning_effort` name the agent's own configuration values: the agent
30# validates them, and an empty one keeps the agent's default. `permission` is
31# this machine's answer to a permission request from the agent: `deny` (the
32# default, it refuses and records a fault) or `allow`.
33# [acp]
34# mode = \"\"
35# model = \"\"
36# reasoning_effort = \"\"
37# permission = \"deny\"
38";
39
40#[derive(Debug, Clone)]
41pub struct InitArgs {
42    pub workspace: PathBuf,
43    pub role: String,
44    pub server_root: PathBuf,
45    /// Role control plane text, copied into the printed spec slice (§5).
46    pub prose: String,
47}
48
49/// The workspace `init` was pointed at is one this build will not write into.
50///
51/// A type rather than a string, because the caller answers it with its own exit
52/// code: refusing to start on a tree from another revision is a different
53/// situation from a run that failed, and a supervisor needs to tell them apart.
54/// Matching on `"legacy workspace"` compared a sentence.
55#[derive(Debug)]
56pub struct LegacyWorkspace;
57
58impl std::fmt::Display for LegacyWorkspace {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.write_str("legacy workspace")
61    }
62}
63
64impl std::error::Error for LegacyWorkspace {}
65
66/// the reconnect window `init` seeds (as a comment) among the top-level keys of
67/// a fresh workspace config, above the `[server]` header beside `backend`. The
68/// key and its default are the ones `onlyne-config`'s `ClientConfig` declares,
69/// and the sweep that reads it runs inside `onlyne client run`.
70const RECONNECT_COMMENTS: &str = "\
71# Seconds a dropped plugin connection may stay away before this client retires
72# the session it left behind. A session with a task bound goes with it: an
73# unsettled task ends `failed`, the delivery that session still held is refused
74# with reason `session_dead`, and the session's own exit is published. An agent
75# that reconnects inside the window keeps its session; a connection that returns
76# after a newer session took the task is held read-only, and what it sends rides
77# that session's closing handoff. 0 disables the sweep.
78# reconnect_grace_secs = 60
79";
80
81/// The role identity, loaded from `role.key` or generated there.
82///
83/// The file holds the 32-byte ed25519 seed; the spec fragment publishes the
84/// matching public key. Every producer of a role key routes through
85/// `onlyne_net::KeyPair`, so the file, the fragment, and the handshake all
86/// describe one identity.
87fn role_key(path: &Path) -> Result<KeyPair> {
88    if path.exists() {
89        return KeyPair::load(path).map_err(|error| anyhow!(error.to_string()));
90    }
91    let key = KeyPair::generate();
92    key.save(path).map_err(|error| anyhow!(error.to_string()))?;
93    Ok(key)
94}
95
96fn server_section(root: &Path) -> Result<(String, String, String)> {
97    let layout = ServerRoot::resolve(root);
98    let spec = Spec::load(layout.spec_path())?;
99    Ok((spec.server.listen, spec.server.cert_pin, spec.server.name))
100}
101
102pub async fn init(args: InitArgs) -> Result<String> {
103    if let Some(reason) = detect_legacy(&args.workspace) {
104        eprintln!(
105            "{}",
106            onlyne_proto::legacy_workspace_message(&[reason.to_string()])
107        );
108        return Err(anyhow::Error::new(LegacyWorkspace));
109    }
110    let workspace = RoleWorkspace::resolve(&args.workspace);
111    workspace.bootstrap()?;
112    let key = role_key(&workspace.key_path())?;
113    let (listen, cert_pin, _server_name) = server_section(&args.server_root)?;
114    let (host, port) = listen
115        .rsplit_once(':')
116        .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(0)))
117        .unwrap_or((listen, 0));
118    let config = format!(
119        "role = {role:?}\ncert_pin = {pin:?}\nkey_path = {key_path:?}\nplugins = []\n\n{PLACEMENT_COMMENTS}\n{RECONNECT_COMMENTS}\n[server]\nhost = {host:?}\nport = {port}\n\n{ACP_COMMENTS}",
120        role = args.role,
121        pin = cert_pin,
122        key_path = workspace.key_path().display().to_string(),
123        host = host,
124        port = port,
125    );
126    std::fs::write(workspace.config_path(), config)?;
127    Ok(fragment(&args.role, &key.public_str(), &args.prose))
128}
129
130/// The `[client.runtime]` seed the printed fragment ships.
131///
132/// The bytes match the seed `examples/supervisor/run.py` writes into its ring
133/// entries, so a pasted fragment spawns the same session the demo cluster runs.
134/// The table is written last, because a key after it belongs to it.
135const SEED_RUNTIME: &str = "\
136[client.runtime]\n\
137drive = \"plugin\"\n\
138command = [\"pi\", \"--session-id\", \"{session}\", \"--session-dir\", \".pi/sessions\", \"-ns\"]";
139
140/// The `[[client]]` slice `init` prints for `spec.toml`.
141///
142/// The ACL lines make the role deliverable to itself, which is what the
143/// end-to-end script exercises with `send --from planner --to planner`.
144/// `public_key` arrives in the `ed25519/<base64>` form `KeyPair::public_str`
145/// produces, which is the same string the handshake verifies against. The
146/// `[client.runtime]` table is the drive and the argv the client runs per
147/// session (§5, §6): a role entry whose table carries no command leaves every
148/// delivery staged with no process behind it.
149pub fn fragment(role: &str, public_key: &str, prose: &str) -> String {
150    let role = toml_string(role);
151    let key = toml_string(public_key);
152    let prose = toml_string(prose);
153    format!(
154        "[[client]]\nrole = {role}\nkey = {key}\nadmin = false\nmax_sessions = 1\nallowed_senders = [\"*\", {role}]\nallowed_targets = [{role}]\nprose = {prose}\n{KNOB_COMMENTS}\n{runtime}\n",
155        runtime = SEED_RUNTIME,
156    )
157}
158
159/// The implemented-but-unprinted `[[client]]` keys, carried as comment lines
160/// so the entry an operator pastes is the whole vocabulary. Each line shows
161/// the default the parser applies; uncommenting one changes what the entry
162/// says, leaving every line commented changes nothing. The field names and
163/// values are the ones `onlyne-config`'s `ClientEntry` declares.
164const KNOB_COMMENTS: &str = "\
165# timeout = { ready_ms = 30000, idle_ms = 60000 }
166# Per-session budgets in milliseconds; the server projects them into the hello
167# reply as `timeout_ready_ms` and `timeout_idle_ms`.
168# intent = { attempts = 3, backoff_ms = [1000, 2000, 4000] }
169# Retry policy for one intent: total attempts, then the per-retry waits in
170# milliseconds; a longer list repeats its last entry.
171# aggregate = \"\"
172# The child-cluster name this role stands for. It is an annotation only: no
173# delivery decision reads it, and a plain role leaves it empty.
174# The [client.runtime] table below is the drive and the argv a session runs.
175# `drive` names how the client talks to the runtime: plugin (the default)
176# starts it and lets its plugin dial back, acp runs the agent as a child over
177# stdio (placement headless only), and exec runs the command and reads its exit
178# code. `command` is the argv, with {session} and {task} substituted; any other
179# brace is refused.
180";
181
182/// One TOML basic string, with the characters a basic string cannot hold escaped.
183pub fn toml_string(text: &str) -> String {
184    let mut out = String::with_capacity(text.len() + 2);
185    out.push('"');
186    for character in text.chars() {
187        match character {
188            '"' => out.push_str("\\\""),
189            '\\' => out.push_str("\\\\"),
190            '\n' => out.push_str("\\n"),
191            '\r' => out.push_str("\\r"),
192            '\t' => out.push_str("\\t"),
193            other => out.push(other),
194        }
195    }
196    out.push('"');
197    out
198}
199
200pub fn legacy_error_code() -> i32 {
201    2
202}