Skip to main content

opencode_ctl/
doctor.rs

1//! `doctor-json`: the eighteen checks behind the `opencode-ctl-doctor` skill (spec Skills).
2//!
3//! The doctor COMPUTES the checks in a fixed order (`ctl_home_override`, `binary`, `registration`,
4//! `server_info`, then the rest in id order) and EMITS them in id order. `registration` is read
5//! before anything can start the service, so an `ok_started` cannot rewrite the file it reports on.
6//! `server_info` is the ONE place that starts a stopped service, explicitly; every other check
7//! leaves the service alone: `wait_route` never fetches the OpenAPI document itself (it reads the
8//! table `server.info` already cached) and `registration` never calls `server.info`.
9//!
10//! Every pair decision keys on the FILE NAME's id (`pair.<id>.json`), never on the id inside the
11//! record: `records_by_name` enumerates the files itself and overwrites the record's inner id.
12
13use std::path::{Path, PathBuf};
14
15use serde_json::{json, Map, Value};
16use sha2::{Digest, Sha256};
17
18use crate::cli::blank_is_unset;
19use crate::clock::Clock;
20use crate::compact::redact_text;
21use crate::engine::Engine;
22use crate::error::{ErrorCode, ToolError};
23use crate::host::Host;
24use crate::liveness::{side_liveness, Liveness, Side};
25use crate::openapi::TableSource;
26use crate::pairing::{list_records, read_record, Record};
27use crate::settings::{self, Note, NoteReason, Source};
28use crate::transport::cli::resolve_binary;
29use crate::transport::{parse_cli_version, service_running, Lifecycle, Transport};
30
31/// The check ids, in emit order.
32pub const CHECK_IDS: [&str; 18] = [
33    "ctl_home_override",
34    "binary",
35    "version_range",
36    "registration",
37    "server_info",
38    "wait_route",
39    "pairing",
40    "lock_source",
41    "env_vs_user_file",
42    "windows_source_old",
43    "launcher_link",
44    "server_url_password",
45    "opencode_password_set",
46    "log",
47    "orphaned_controllers",
48    "orphaned_workers",
49    "settings_notes",
50    "settings_file_hash",
51];
52
53pub struct DoctorCtx<'a> {
54    /// The launcher install dir (`~/.local/bin`, `%LOCALAPPDATA%\opencode-ctl`).
55    pub bin_dir: &'a Path,
56    /// `std::env::current_exe()`.
57    pub running_exe: &'a Path,
58    /// The front's `CARGO_PKG_VERSION`, for `launcher_link_status`.
59    pub version: &'a str,
60    /// The OS home: third input of `resolve_binary`.
61    pub user_home: &'a Path,
62    /// `env_nonempty("PATH")`: second input of `resolve_binary`.
63    pub path: Option<String>,
64    /// The CLI's `Service.Info` registration file (packages/client/src/effect/service.ts:146).
65    pub registration_file: Option<&'a Path>,
66    /// The CLI's `ServiceConfig.Info` file (packages/cli/src/services/service-config.ts:16).
67    pub config_file: Option<&'a Path>,
68    /// The RAW environment lookup. Every read except `OPENCODE_CTL_SERVER` goes through `blank_is_unset`.
69    pub env: &'a dyn Fn(&str) -> Option<String>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Status {
74    Ok,
75    Warn,
76    Fail,
77    Skip,
78}
79
80impl Status {
81    pub fn as_str(self) -> &'static str {
82        match self {
83            Status::Ok => "ok",
84            Status::Warn => "warn",
85            Status::Fail => "fail",
86            Status::Skip => "skip",
87        }
88    }
89}
90
91#[derive(Debug, Clone)]
92pub struct Check {
93    pub id: &'static str,
94    pub status: Status,
95    pub message: String,
96    pub details: Option<Value>,
97}
98
99impl Check {
100    fn new(id: &'static str, status: Status, variant: &str, subs: &[(&str, &str)]) -> Check {
101        Check {
102            id,
103            status,
104            message: message(id, status, variant, subs),
105            details: None,
106        }
107    }
108
109    fn details(mut self, d: Value) -> Check {
110        self.details = Some(d);
111        self
112    }
113
114    pub fn to_json(&self) -> Value {
115        let mut v = json!({"id": self.id, "status": self.status.as_str(), "message": self.message});
116        if let Some(d) = &self.details {
117            v["details"] = d.clone();
118        }
119        v
120    }
121}
122
123const SIDES: &str = "pair <pair>: tui <tui>; controller <controller>; generation <generation> sentinel <sentinel>; session <session> <session_state>";
124
125/// Every message the doctor can print, keyed by `(id, status, variant)`; `<...>` placeholders are
126/// filled by `message`. A check with several texts per status has one variant tag per text.
127const MESSAGES: &[(&str, &str, &str, &str)] = &[
128    ("ctl_home_override", "ok", "", "OPENCODE_CTL_HOME is not set"),
129    ("ctl_home_override", "warn", "", "OPENCODE_CTL_HOME is set to <path>: the settings file and the pair files are read from there"),
130    ("binary", "ok", "", "opencode found at <path>"),
131    ("binary", "fail", "", "opencode executable not found: install opencode or set OPENCODE_CTL_BIN"),
132    ("binary", "fail", "missing_explicit", "the configured opencode_bin <path> is not a file"),
133    ("version_range", "skip", "", "binary failed"),
134    ("version_range", "fail", "unparsable", "unparsable version output"),
135    ("version_range", "fail", "below", "<cli> is below 2.0"),
136    ("version_range", "fail", "cli_error", "cannot run opencode --version: <message>"),
137    ("version_range", "warn", "server_outside_range", "server <server> is outside the tested 2.0.x range"),
138    ("version_range", "ok", "cli_only", "server version unknown; CLI <cli>"),
139    ("version_range", "ok", "", "CLI <cli> and server <server> are within the tested 2.0.x range"),
140    ("registration", "skip", "", "no registration file at <path>"),
141    ("registration", "skip", "no_path", "no registration file location is known"),
142    ("registration", "warn", "unreadable", "registration file <path> is not a valid service registration"),
143    ("registration", "fail", "", "registered pid <pid> is dead"),
144    ("registration", "warn", "disabled", "service disabled in <config_file>: every CLI call runs a private standalone server"),
145    ("registration", "ok", "", "registered pid <pid> is alive"),
146    ("server_info", "ok", "", "server.info answered: version <version>, pid <pid>"),
147    ("server_info", "ok", "ok_started", "service was stopped; opencode service start brought it up"),
148    ("server_info", "fail", "fail_start", "service stopped and opencode service start failed: <stderr>"),
149    ("server_info", "fail", "fail_unreachable", "cannot reach <url>: <message>"),
150    ("server_info", "fail", "fail_auth", "server at <url> rejected the credentials: compare `opencode service get password` with OPENCODE_SERVER_PASSWORD"),
151    ("server_info", "fail", "fail_other", "server.info failed (<code>): <message>"),
152    ("wait_route", "skip", "", "server_info failed"),
153    ("wait_route", "ok", "", "experimental.session.wait is in the operation table"),
154    ("wait_route", "warn", "missing", "experimental.session.wait is not in the operation table: session_wait falls back to polling"),
155    ("wait_route", "warn", "unconfirmed", "the live operation table is unavailable, so experimental.session.wait could not be confirmed"),
156    ("pairing", "skip", "", "not paired: OPENCODE_CTL_PAIR is not set"),
157    ("pairing", "fail", "record", "no usable pair record for <pair>"),
158    ("pairing", "fail", "unreadable", "pair record for <pair> is unreadable: <error>"),
159    ("pairing", "fail", "session", SIDES),
160    ("pairing", "warn", "", SIDES),
161    ("pairing", "ok", "", SIDES),
162    ("lock_source", "ok", "", "permissions_locked=<locked> (<locked_source>); permission_policy=<policy> (<policy_source>)"),
163    ("env_vs_user_file", "ok", "", "no protected key's environment value differs from the user-level file"),
164    ("env_vs_user_file", "warn", "", "environment overrides the user-level file for: <keys>"),
165    ("windows_source_old", "skip", "off_windows", "not applicable off Windows"),
166    ("windows_source_old", "skip", "", "no launcher copy at <path>"),
167    ("windows_source_old", "ok", "", "opencode-pair.exe.source is valid and no .old leftover exists"),
168    ("windows_source_old", "warn", "", "launcher copy problems: <problems>"),
169    ("launcher_link", "skip", "", "not offered on this host"),
170    ("launcher_link", "ok", "", "<link> resolves to the running binary"),
171    ("launcher_link", "warn", "missing", "missing: <link> does not exist; run install-launcher"),
172    ("launcher_link", "warn", "stale", "stale: <link> does not match the running binary (version <version>); re-run install-launcher"),
173    ("launcher_link", "warn", "other_host", "other_host: <link> belongs to another host's binary; run install-launcher --force to take it over"),
174    ("launcher_link", "warn", "not_a_link", "not_a_link: <link> is not a link install-launcher made; it is left alone and install-launcher reports LAUNCHER_CONFLICT"),
175    ("server_url_password", "skip", "", "server_url is not set"),
176    ("server_url_password", "ok", "", "server_url is set and OPENCODE_SERVER_PASSWORD is present"),
177    ("server_url_password", "warn", "", "server_url is set (<url>) but OPENCODE_SERVER_PASSWORD is not: the CLI cannot authenticate"),
178    ("opencode_password_set", "ok", "", "OPENCODE_PASSWORD is not set"),
179    ("opencode_password_set", "warn", "", "OPENCODE_PASSWORD is set: the CLI reads it before OPENCODE_SERVER_PASSWORD"),
180    ("log", "skip", "", "no log file in <dir>"),
181    ("log", "ok", "", "newest log <path>: no error line"),
182    ("log", "warn", "", "newest log <path>: last error: <line>"),
183    ("orphaned_controllers", "ok", "", "no pair has a dead controller beside a live TUI"),
184    ("orphaned_controllers", "warn", "", "controller dead while the TUI is alive for pair(s): <ids>"),
185    ("orphaned_workers", "skip", "", "server_info failed"),
186    ("orphaned_workers", "ok", "", "no active session belongs to a pair without a living side"),
187    ("orphaned_workers", "warn", "", "active session(s) whose pair has no living side: <ids>"),
188    ("orphaned_workers", "warn", "api", "cannot list active sessions: <message>"),
189    ("settings_notes", "ok", "", "no settings entry was ignored or clamped"),
190    ("settings_notes", "warn", "", "<count> settings entries ignored or clamped: <list>"),
191    ("settings_file_hash", "skip", "", "no user-level settings file at <path>"),
192    ("settings_file_hash", "ok", "", "<path>: sha256 <hash>, modified <mtime_ms> ms since the epoch"),
193    ("settings_file_hash", "warn", "", "cannot read <path>: <message>"),
194];
195
196fn message(id: &str, status: Status, variant: &str, subs: &[(&str, &str)]) -> String {
197    let status = status.as_str();
198    let template = MESSAGES
199        .iter()
200        .find(|(i, s, v, _)| *i == id && *s == status && *v == variant)
201        .unwrap_or_else(|| panic!("no doctor message for ({id}, {status}, {variant})"))
202        .3;
203    let mut out = template.to_string();
204    for (name, value) in subs {
205        out = out.replace(&format!("<{name}>"), value);
206    }
207    out
208}
209
210// ------------------------------------------------------------------ small helpers
211
212fn source_name(s: Source) -> &'static str {
213    match s {
214        Source::Env => "env",
215        Source::UserFile => "user file",
216        Source::WorkspaceFile => "workspace file",
217        Source::Default => "default",
218    }
219}
220
221fn hex(bytes: &[u8]) -> String {
222    bytes.iter().map(|b| format!("{b:02x}")).collect()
223}
224
225fn first_line(s: &str) -> &str {
226    s.lines().next().unwrap_or("")
227}
228
229fn mtime_ms(meta: &std::fs::Metadata) -> Option<i64> {
230    let d = meta
231        .modified()
232        .ok()?
233        .duration_since(std::time::UNIX_EPOCH)
234        .ok()?;
235    Some(d.as_millis() as i64)
236}
237
238/// The registration file's default location: `$XDG_STATE_HOME/opencode/service.json`, else
239/// `~/.local/state/opencode/service.json` (packages/util/src/global.ts:79 roots).
240pub fn default_registration_file(
241    get: &dyn Fn(&str) -> Option<String>,
242    user_home: &Path,
243) -> PathBuf {
244    let base = blank_is_unset(get("XDG_STATE_HOME"))
245        .map(PathBuf::from)
246        .unwrap_or_else(|| user_home.join(".local").join("state"));
247    base.join("opencode").join("service.json")
248}
249
250/// The service config's default location: `$OPENCODE_CONFIG_DIR/service.json`
251/// (packages/util/src/global.ts:79), else `$XDG_CONFIG_HOME/opencode/`, else `~/.config/opencode/`.
252pub fn default_config_file(get: &dyn Fn(&str) -> Option<String>, user_home: &Path) -> PathBuf {
253    if let Some(dir) = blank_is_unset(get("OPENCODE_CONFIG_DIR")) {
254        return PathBuf::from(dir).join("service.json");
255    }
256    let base = blank_is_unset(get("XDG_CONFIG_HOME"))
257        .map(PathBuf::from)
258        .unwrap_or_else(|| user_home.join(".config"));
259    base.join("opencode").join("service.json")
260}
261
262// ------------------------------------------------------------------ launcher link
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum LinkStatus {
266    Fresh,
267    Stale,
268    OtherHost,
269    Missing,
270    NotALink,
271}
272
273impl LinkStatus {
274    fn word(self) -> &'static str {
275        match self {
276            LinkStatus::Fresh => "fresh",
277            LinkStatus::Stale => "stale",
278            LinkStatus::OtherHost => "other_host",
279            LinkStatus::Missing => "missing",
280            LinkStatus::NotALink => "not_a_link",
281        }
282    }
283}
284
285const BINARY_NAMES: [&str; 3] = [
286    "opencode-ctl-cc-mcp",
287    "opencode-ctl-qwen-mcp",
288    "opencode-ctl-agy-mcp",
289];
290
291fn link_name() -> &'static str {
292    if cfg!(windows) {
293        "opencode-pair.exe"
294    } else {
295        "opencode-pair"
296    }
297}
298
299/// The two lines of `opencode-pair.exe.source`: `path=<staged path>` and `version=<plugin semver>`
300/// (spec install-launcher; split on the first `=`, any parse failure counts as absent).
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct SourceFile {
303    pub path: String,
304    pub version: String,
305}
306
307pub fn parse_source_file(raw: &str) -> Option<SourceFile> {
308    let (mut path, mut version) = (None, None);
309    for line in raw.trim_start_matches('\u{feff}').lines() {
310        match line.split_once('=') {
311            Some(("path", v)) if !v.is_empty() => path = Some(v.to_string()),
312            Some(("version", v)) if !v.is_empty() => version = Some(v.to_string()),
313            _ => {}
314        }
315    }
316    Some(SourceFile {
317        path: path?,
318        version: version?,
319    })
320}
321
322/// A file name with the `.exe` suffix dropped, from either kind of separator.
323fn stem_of(path: &str) -> String {
324    let name = path.rsplit(['/', '\\']).next().unwrap_or("");
325    let lower = name.to_ascii_lowercase();
326    match lower.strip_suffix(".exe") {
327        Some(_) => name[..name.len() - 4].to_string(),
328        None => name.to_string(),
329    }
330}
331
332fn same_path(a: &str, b: &str) -> bool {
333    if cfg!(windows) {
334        a.eq_ignore_ascii_case(b)
335    } else {
336        a == b
337    }
338}
339
340/// Which kind of binary a staged or linked file name belongs to, relative to `host`.
341fn name_status(stem: &str, host: Host) -> Option<LinkStatus> {
342    if stem == host.binary_name() {
343        Some(LinkStatus::Stale)
344    } else if BINARY_NAMES.contains(&stem) {
345        Some(LinkStatus::OtherHost)
346    } else {
347        None
348    }
349}
350
351/// The Windows rule on already-read inputs (pure, so it is tested on every platform): `Stale` when
352/// the `.source` `path=` differs from the running exe OR its `version=` differs from `version`.
353pub fn windows_link_status(
354    copy_exists: bool,
355    source: Option<&SourceFile>,
356    running_exe: &Path,
357    host: Host,
358    version: &str,
359) -> LinkStatus {
360    if !copy_exists {
361        return LinkStatus::Missing;
362    }
363    let Some(src) = source else {
364        return LinkStatus::NotALink;
365    };
366    if same_path(&src.path, &running_exe.to_string_lossy()) {
367        return if src.version == version {
368            LinkStatus::Fresh
369        } else {
370            LinkStatus::Stale
371        };
372    }
373    name_status(&stem_of(&src.path), host).unwrap_or(LinkStatus::NotALink)
374}
375
376/// POSIX: the `opencode-pair` symlink's target; Windows: the copy and its `.source` file.
377pub fn launcher_link_status(
378    bin_dir: &Path,
379    running_exe: &Path,
380    host: Host,
381    version: &str,
382) -> LinkStatus {
383    let link = bin_dir.join(link_name());
384    #[cfg(windows)]
385    {
386        let source = std::fs::read_to_string(bin_dir.join("opencode-pair.exe.source"))
387            .ok()
388            .and_then(|raw| parse_source_file(&raw));
389        windows_link_status(link.is_file(), source.as_ref(), running_exe, host, version)
390    }
391    #[cfg(not(windows))]
392    {
393        let _ = version;
394        let meta = match std::fs::symlink_metadata(&link) {
395            Ok(m) => m,
396            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return LinkStatus::Missing,
397            Err(_) => return LinkStatus::NotALink,
398        };
399        if !meta.file_type().is_symlink() {
400            return LinkStatus::NotALink;
401        }
402        let Ok(target) = std::fs::read_link(&link) else {
403            return LinkStatus::NotALink;
404        };
405        let target = bin_dir.join(target);
406        let resolves = target == running_exe
407            || matches!(
408                (std::fs::canonicalize(&target), std::fs::canonicalize(running_exe)),
409                (Ok(a), Ok(b)) if a == b
410            );
411        if resolves {
412            return LinkStatus::Fresh;
413        }
414        let stem = target
415            .file_name()
416            .map(|n| n.to_string_lossy().into_owned())
417            .unwrap_or_default();
418        name_status(&stem, host).unwrap_or(LinkStatus::NotALink)
419    }
420}
421
422/// `windows_source_old` on a given platform flag (so the Windows rule is tested everywhere):
423/// the copy's `.source` file must parse, and no `.old` leftover may remain.
424pub fn windows_source_old_check(bin_dir: &Path, windows: bool) -> Check {
425    const ID: &str = "windows_source_old";
426    if !windows {
427        return Check::new(ID, Status::Skip, "off_windows", &[]);
428    }
429    let copy = bin_dir.join("opencode-pair.exe");
430    if !copy.is_file() {
431        return Check::new(
432            ID,
433            Status::Skip,
434            "",
435            &[("path", &copy.display().to_string())],
436        );
437    }
438    let source = bin_dir.join("opencode-pair.exe.source");
439    let old = bin_dir.join("opencode-pair.exe.old");
440    let mut problems = Vec::new();
441    let parsed = std::fs::read_to_string(&source)
442        .ok()
443        .and_then(|raw| parse_source_file(&raw));
444    if parsed.is_none() {
445        problems.push(format!(
446            "{} is missing or unparsable (re-run install-launcher)",
447            source.display()
448        ));
449    }
450    if old.exists() {
451        problems.push(format!(
452            "{} is left over from an in-use replacement (gc deletes it)",
453            old.display()
454        ));
455    }
456    if problems.is_empty() {
457        Check::new(ID, Status::Ok, "", &[])
458    } else {
459        Check::new(ID, Status::Warn, "", &[("problems", &problems.join("; "))])
460    }
461}
462
463fn launcher_link_check<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
464    const ID: &str = "launcher_link";
465    if !engine.host.offers_launcher() {
466        return Check::new(ID, Status::Skip, "", &[]);
467    }
468    let link = ctx.bin_dir.join(link_name());
469    let status = launcher_link_status(ctx.bin_dir, ctx.running_exe, engine.host, ctx.version);
470    let l = link.display().to_string();
471    let subs = [("link", l.as_str()), ("version", ctx.version)];
472    let (st, variant) = match status {
473        LinkStatus::Fresh => (Status::Ok, ""),
474        LinkStatus::Missing => (Status::Warn, "missing"),
475        LinkStatus::Stale => (Status::Warn, "stale"),
476        LinkStatus::OtherHost => (Status::Warn, "other_host"),
477        LinkStatus::NotALink => (Status::Warn, "not_a_link"),
478    };
479    Check::new(ID, st, variant, &subs).details(json!({"link": status.word(), "path": l}))
480}
481
482// ------------------------------------------------------------------ pair files (file-name ids)
483
484fn side_words(side: Side, l: Liveness) -> String {
485    let why = match (l, side) {
486        (Liveness::Alive, Side::Tui) => "the tab is running",
487        (Liveness::Alive, Side::Controller) => "the controller is running",
488        (Liveness::Dead, _) => "the process is gone",
489        (Liveness::Starting, _) => "launched less than 30 s ago, no sentinel yet",
490        (Liveness::Unknown, _) => "the sentinel cannot be verified",
491        (Liveness::None, Side::Tui) => "no tab was requested",
492        (Liveness::None, Side::Controller) => "no controller was recorded",
493    };
494    format!("{} ({why})", l.as_str())
495}
496
497// ------------------------------------------------------------------ the checks
498
499struct ServerState {
500    ok: bool,
501    version: Option<String>,
502}
503
504fn ctl_home_override(ctx: &DoctorCtx) -> Check {
505    match blank_is_unset((ctx.env)("OPENCODE_CTL_HOME")) {
506        Some(p) => Check::new("ctl_home_override", Status::Warn, "", &[("path", &p)]),
507        None => Check::new("ctl_home_override", Status::Ok, "", &[]),
508    }
509}
510
511fn binary_check<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
512    let explicit = engine.settings.opencode_bin.as_deref();
513    match resolve_binary(explicit, ctx.path.as_deref(), ctx.user_home) {
514        Ok(p) if explicit.is_some() && !p.is_file() => Check::new(
515            "binary",
516            Status::Fail,
517            "missing_explicit",
518            &[("path", &p.display().to_string())],
519        ),
520        Ok(p) => Check::new(
521            "binary",
522            Status::Ok,
523            "",
524            &[("path", &p.display().to_string())],
525        ),
526        Err(_) => Check::new("binary", Status::Fail, "", &[]),
527    }
528}
529
530fn registration_check<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
531    const ID: &str = "registration";
532    // A disabled service config selects a private standalone server for every call (server-connection.ts:41),
533    // so it outranks an absent, dead or live registration.
534    let disabled = ctx
535        .config_file
536        .and_then(|f| std::fs::read_to_string(f).ok())
537        .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
538        .is_some_and(|v| v.get("disabled") == Some(&Value::Bool(true)));
539    if disabled {
540        let cfg = ctx.config_file.map(|f| f.display().to_string());
541        return Check::new(
542            ID,
543            Status::Warn,
544            "disabled",
545            &[("config_file", cfg.as_deref().unwrap_or(""))],
546        );
547    }
548    let Some(file) = ctx.registration_file else {
549        return Check::new(ID, Status::Skip, "no_path", &[]);
550    };
551    let shown = file.display().to_string();
552    let Ok(raw) = std::fs::read_to_string(file) else {
553        return if file.exists() {
554            Check::new(ID, Status::Warn, "unreadable", &[("path", &shown)])
555        } else {
556            Check::new(ID, Status::Skip, "", &[("path", &shown)])
557        };
558    };
559    let parsed: Option<(String, u64)> = serde_json::from_str::<Value>(&raw).ok().and_then(|v| {
560        let url = v.get("url")?.as_str()?.to_string();
561        let pid = v.get("pid")?.as_u64().filter(|p| *p > 0)?;
562        Some((url, pid))
563    });
564    let Some((url, pid)) = parsed else {
565        return Check::new(ID, Status::Warn, "unreadable", &[("path", &shown)]);
566    };
567    let pid_s = pid.to_string();
568    let alive = u32::try_from(pid).is_ok_and(|p| engine.probe.is_alive(p));
569    if !alive {
570        return Check::new(ID, Status::Fail, "", &[("pid", &pid_s)])
571            .details(json!({"url": redact_text(&url)}));
572    }
573    Check::new(ID, Status::Ok, "", &[("pid", &pid_s)])
574}
575
576async fn server_info_check<T: Transport, C: Clock>(engine: &Engine<T, C>) -> (Check, ServerState) {
577    const ID: &str = "server_info";
578    let down = |c: Check| {
579        (
580            c,
581            ServerState {
582                ok: false,
583                version: None,
584            },
585        )
586    };
587    let other = |e: &ToolError| {
588        Check::new(
589            ID,
590            Status::Fail,
591            "fail_other",
592            &[("code", e.code.as_str()), ("message", &e.message)],
593        )
594    };
595    let managed = engine.settings.server_url.is_none();
596    let mut url = engine.settings.server_url.clone().unwrap_or_default();
597    let mut started = false;
598    if managed {
599        match engine.lifecycle(Lifecycle::Status).await {
600            Err(e) => return down(other(&e)),
601            Ok(out) if service_running(&out) => url = out.trim().to_string(),
602            Ok(_) => match engine.lifecycle(Lifecycle::Start).await {
603                Err(e) => {
604                    return down(Check::new(
605                        ID,
606                        Status::Fail,
607                        "fail_start",
608                        &[("stderr", first_line(&e.message))],
609                    ))
610                }
611                Ok(out) => {
612                    started = true;
613                    url = out.trim().to_string();
614                }
615            },
616        }
617    }
618    match engine.call_json("server.info", Vec::new(), None).await {
619        Ok((info, _, _)) => {
620            engine.invalidate_if_changed(&info).await;
621            let version = info
622                .get("version")
623                .and_then(Value::as_str)
624                .map(str::to_string);
625            let pid = info.get("pid").and_then(Value::as_u64);
626            let pid_s = pid.map_or("unknown".to_string(), |p| p.to_string());
627            let details = json!({"url": url, "version": version, "pid": pid});
628            let check = if started {
629                Check::new(ID, Status::Ok, "ok_started", &[]).details(
630                    json!({"url": url, "version": version, "pid": pid, "started_by_doctor": true}),
631                )
632            } else {
633                Check::new(
634                    ID,
635                    Status::Ok,
636                    "",
637                    &[
638                        ("version", version.as_deref().unwrap_or("unknown")),
639                        ("pid", &pid_s),
640                    ],
641                )
642                .details(details)
643            };
644            (check, ServerState { ok: true, version })
645        }
646        Err(e) => down(match e.code {
647            ErrorCode::ServiceDown => Check::new(
648                ID,
649                Status::Fail,
650                "fail_unreachable",
651                &[("url", &url), ("message", &e.message)],
652            ),
653            ErrorCode::AuthFailed => Check::new(ID, Status::Fail, "fail_auth", &[("url", &url)]),
654            _ => other(&e),
655        }),
656    }
657}
658
659async fn version_range_check<T: Transport, C: Clock>(
660    engine: &Engine<T, C>,
661    binary: &Check,
662    server: &ServerState,
663) -> Check {
664    const ID: &str = "version_range";
665    if binary.status != Status::Ok {
666        return Check::new(ID, Status::Skip, "", &[]);
667    }
668    let raw = match engine.cli_version().await {
669        Ok(raw) => raw,
670        Err(e) => return Check::new(ID, Status::Fail, "cli_error", &[("message", &e.message)]),
671    };
672    let major = parse_cli_version(&raw).and_then(|v| {
673        let digits: String = v.chars().take_while(char::is_ascii_digit).collect();
674        digits.parse::<u64>().ok().map(|m| (v, m))
675    });
676    let Some((cli, major)) = major else {
677        return Check::new(ID, Status::Fail, "unparsable", &[]);
678    };
679    if major < 2 {
680        return Check::new(ID, Status::Fail, "below", &[("cli", &cli)]);
681    }
682    match server.version.as_deref().filter(|_| server.ok) {
683        None => Check::new(ID, Status::Ok, "cli_only", &[("cli", &cli)]),
684        Some(sv) => {
685            let bare = sv.strip_prefix('v').unwrap_or(sv);
686            if bare == "2.0" || bare.starts_with("2.0.") {
687                Check::new(ID, Status::Ok, "", &[("cli", &cli), ("server", sv)])
688            } else {
689                Check::new(ID, Status::Warn, "server_outside_range", &[("server", sv)])
690            }
691        }
692    }
693}
694
695async fn wait_route_check<T: Transport, C: Clock>(
696    engine: &Engine<T, C>,
697    server: &ServerState,
698) -> Check {
699    const ID: &str = "wait_route";
700    if !server.ok {
701        return Check::new(ID, Status::Skip, "", &[]);
702    }
703    // After a successful `server.info` the live table is cached, so this does not fetch again.
704    let (table, source, _) = engine.operation_table().await;
705    if source != TableSource::Live {
706        Check::new(ID, Status::Warn, "unconfirmed", &[])
707    } else if table.get("experimental.session.wait").is_some() {
708        Check::new(ID, Status::Ok, "", &[])
709    } else {
710        Check::new(ID, Status::Warn, "missing", &[])
711    }
712}
713
714async fn pairing_check<T: Transport, C: Clock>(
715    engine: &Engine<T, C>,
716    server: &ServerState,
717) -> Check {
718    const ID: &str = "pairing";
719    let Some(pair) = engine.env.pair.as_deref() else {
720        return Check::new(ID, Status::Skip, "", &[]);
721    };
722    let home = &engine.env.home;
723    let record = match read_record(home, pair) {
724        Ok(Some(r)) => r,
725        Ok(None) => return Check::new(ID, Status::Fail, "record", &[("pair", pair)]),
726        Err(e) => {
727            return Check::new(
728                ID,
729                Status::Fail,
730                "unreadable",
731                &[("pair", pair), ("error", &e.to_string())],
732            )
733        }
734    };
735    let now = engine.clock().now_ms();
736    let tui = side_liveness(Side::Tui, &record, home, &*engine.probe, now);
737    let controller = side_liveness(Side::Controller, &record, home, &*engine.probe, now);
738    let sentinel = if !record.tab {
739        "not applicable (no tab)"
740    } else if home
741        .join(format!("pair.{pair}.g{}.tui-pid", record.generation))
742        .is_file()
743    {
744        "present"
745    } else {
746        "missing"
747    };
748    let session_id = engine
749        .env
750        .session
751        .clone()
752        .unwrap_or_else(|| record.session_id.clone());
753    let (state, state_key) = if !server.ok {
754        ("skipped".to_string(), "skipped")
755    } else {
756        match engine
757            .call_json(
758                "session.get",
759                vec![("sessionID".to_string(), session_id.clone())],
760                None,
761            )
762            .await
763        {
764            Ok(_) => ("found".to_string(), "found"),
765            Err(e) if e.code == ErrorCode::SessionNotFound => {
766                ("not found".to_string(), "not_found")
767            }
768            Err(e) => (format!("lookup failed ({})", e.code.as_str()), "error"),
769        }
770    };
771    let faulty = |l: Liveness| matches!(l, Liveness::Dead | Liveness::Unknown);
772    let (status, variant) = if state_key == "not_found" {
773        (Status::Fail, "session")
774    } else if faulty(tui) || faulty(controller) || sentinel == "missing" || state_key == "error" {
775        (Status::Warn, "")
776    } else {
777        (Status::Ok, "")
778    };
779    let (tui_s, ctl_s) = (
780        side_words(Side::Tui, tui),
781        side_words(Side::Controller, controller),
782    );
783    let generation = record.generation.to_string();
784    Check::new(
785        ID,
786        status,
787        variant,
788        &[
789            ("pair", pair),
790            ("tui", &tui_s),
791            ("controller", &ctl_s),
792            ("generation", &generation),
793            ("sentinel", sentinel),
794            ("session", &session_id),
795            ("session_state", &state),
796        ],
797    )
798    .details(json!({
799        "pair_id": pair,
800        "session_id": session_id,
801        "tui": tui.as_str(),
802        "controller": controller.as_str(),
803        "generation": record.generation,
804        "sentinel": sentinel,
805        "session": state_key,
806    }))
807}
808
809fn lock_source_check<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
810    let s = &engine.settings;
811    let src = |k: &str| source_name(s.sources.get(k).copied().unwrap_or(Source::Default));
812    Check::new(
813        "lock_source",
814        Status::Ok,
815        "",
816        &[
817            ("locked", &s.permissions_locked.to_string()),
818            ("locked_source", src("permissions_locked")),
819            ("policy", s.permission_policy.as_str()),
820            ("policy_source", src("permission_policy")),
821        ],
822    )
823}
824
825/// The protected keys (spec Settings), in the order the doctor lists them.
826const PROTECTED: [&str; 7] = [
827    "opencode_bin",
828    "server_url",
829    "default_model",
830    "default_agent",
831    "permission_policy",
832    "permissions_locked",
833    "wait_max_s",
834];
835
836fn env_vs_user_file<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
837    const ID: &str = "env_vs_user_file";
838    let file = std::fs::read_to_string(engine.env.home.join("settings.local.md"))
839        .map(|raw| settings::parse_frontmatter(&raw))
840        .unwrap_or_default();
841    let mut found = Vec::new();
842    for key in PROTECTED {
843        let name = settings::env_name(key);
844        // `OPENCODE_CTL_SERVER` is read raw: an empty value is the background-service override.
845        let env = if key == "server_url" {
846            (ctx.env)(&name)
847        } else {
848            blank_is_unset((ctx.env)(&name))
849        };
850        let from_file = file
851            .iter()
852            .find(|(k, v)| k == key && !v.trim().is_empty())
853            .map(|(_, v)| v.as_str());
854        if let (Some(e), Some(f)) = (env, from_file) {
855            if e.trim() != f.trim() {
856                found.push((key, redact_text(&e), redact_text(f)));
857            }
858        }
859    }
860    if found.is_empty() {
861        return Check::new(ID, Status::Ok, "", &[]);
862    }
863    let list: Vec<String> = found
864        .iter()
865        .map(|(k, e, f)| format!("{k} (env \"{e}\", file \"{f}\")"))
866        .collect();
867    Check::new(ID, Status::Warn, "", &[("keys", &list.join(", "))]).details(json!({
868        "keys": found.iter().map(|(k, e, f)| json!({"key": k, "env": e, "file": f})).collect::<Vec<_>>(),
869    }))
870}
871
872fn server_url_password<T: Transport, C: Clock>(engine: &Engine<T, C>, ctx: &DoctorCtx) -> Check {
873    const ID: &str = "server_url_password";
874    let Some(url) = engine.settings.server_url.as_deref() else {
875        return Check::new(ID, Status::Skip, "", &[]);
876    };
877    if blank_is_unset((ctx.env)("OPENCODE_SERVER_PASSWORD")).is_some() {
878        Check::new(ID, Status::Ok, "", &[])
879    } else {
880        Check::new(ID, Status::Warn, "", &[("url", &redact_text(url))])
881    }
882}
883
884fn opencode_password_set(ctx: &DoctorCtx) -> Check {
885    if blank_is_unset((ctx.env)("OPENCODE_PASSWORD")).is_some() {
886        Check::new("opencode_password_set", Status::Warn, "", &[])
887    } else {
888        Check::new("opencode_password_set", Status::Ok, "", &[])
889    }
890}
891
892fn log_check<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
893    const ID: &str = "log";
894    let dir = engine.env.home.join("logs");
895    let newest = std::fs::read_dir(&dir)
896        .into_iter()
897        .flatten()
898        .flatten()
899        .filter(|e| {
900            let n = e.file_name().to_string_lossy().into_owned();
901            n.ends_with(".log") && (n.starts_with("serve-") || n.starts_with("pair-"))
902        })
903        .filter_map(|e| {
904            let m = e.metadata().ok()?;
905            Some((mtime_ms(&m)?, e.file_name(), e.path()))
906        })
907        .max_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
908    let Some((_, _, path)) = newest else {
909        return Check::new(ID, Status::Skip, "", &[("dir", &dir.display().to_string())]);
910    };
911    let shown = path.display().to_string();
912    let text = std::fs::read(&path)
913        .map(|b| String::from_utf8_lossy(&b).into_owned())
914        .unwrap_or_default();
915    let last = text
916        .lines()
917        .rev()
918        .find(|l| l.split_whitespace().any(|w| w == "ERROR"));
919    match last {
920        None => Check::new(ID, Status::Ok, "", &[("path", &shown)]),
921        Some(line) => {
922            let line: String = redact_text(line.trim()).chars().take(300).collect();
923            Check::new(ID, Status::Warn, "", &[("path", &shown), ("line", &line)])
924                .details(json!({"path": shown, "last_error": line}))
925        }
926    }
927}
928
929fn orphaned_controllers<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
930    let home = &engine.env.home;
931    let now = engine.clock().now_ms();
932    let ids: Vec<String> = list_records(home)
933        .into_iter()
934        .filter(|r| {
935            side_liveness(Side::Controller, r, home, &*engine.probe, now) == Liveness::Dead
936                && side_liveness(Side::Tui, r, home, &*engine.probe, now) == Liveness::Alive
937        })
938        .map(|r| r.pair_id)
939        .collect();
940    if ids.is_empty() {
941        Check::new("orphaned_controllers", Status::Ok, "", &[])
942    } else {
943        Check::new(
944            "orphaned_controllers",
945            Status::Warn,
946            "",
947            &[("ids", &ids.join(", "))],
948        )
949        .details(json!({"pairs": ids}))
950    }
951}
952
953async fn orphaned_workers<T: Transport, C: Clock>(
954    engine: &Engine<T, C>,
955    server: &ServerState,
956) -> Check {
957    const ID: &str = "orphaned_workers";
958    if !server.ok {
959        return Check::new(ID, Status::Skip, "", &[]);
960    }
961    let active = match engine.call_json("session.active", Vec::new(), None).await {
962        Ok((data, _, _)) => data,
963        Err(e) => return Check::new(ID, Status::Warn, "api", &[("message", &e.message)]),
964    };
965    let home = &engine.env.home;
966    let now = engine.clock().now_ms();
967    let records = list_records(home);
968    let living = |l: Liveness| matches!(l, Liveness::Alive | Liveness::Starting);
969    let mut ids: Vec<String> = active
970        .as_object()
971        .map(|o| o.keys().cloned().collect())
972        .unwrap_or_default();
973    ids.sort();
974    ids.retain(|id| {
975        let mine: Vec<&Record> = records.iter().filter(|r| &r.session_id == id).collect();
976        !mine.is_empty()
977            && !mine.iter().any(|r| {
978                living(side_liveness(Side::Tui, r, home, &*engine.probe, now))
979                    || living(side_liveness(
980                        Side::Controller,
981                        r,
982                        home,
983                        &*engine.probe,
984                        now,
985                    ))
986            })
987    });
988    if ids.is_empty() {
989        Check::new(ID, Status::Ok, "", &[])
990    } else {
991        Check::new(ID, Status::Warn, "", &[("ids", &ids.join(", "))])
992            .details(json!({"sessions": ids}))
993    }
994}
995
996fn reason_words(r: &NoteReason) -> String {
997    match r {
998        NoteReason::UnknownKey => "unknown key".to_string(),
999        NoteReason::InvalidValue => "invalid value".to_string(),
1000        NoteReason::Clamped { min, max } => format!("clamped to {min}..{max}"),
1001        NoteReason::ProtectedInWorkspace => "protected key in a workspace file".to_string(),
1002        NoteReason::OverriddenByStickyLock => "overridden by the permission lock".to_string(),
1003        NoteReason::UserinfoInUrl => "userinfo in the URL".to_string(),
1004    }
1005}
1006
1007fn note_json(n: &Note) -> Value {
1008    json!({
1009        "source": source_name(n.source),
1010        "key": n.key,
1011        "value": redact_text(&n.value),
1012        "reason": reason_words(&n.reason),
1013    })
1014}
1015
1016fn settings_notes<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
1017    if engine.notes.is_empty() {
1018        return Check::new("settings_notes", Status::Ok, "", &[]);
1019    }
1020    let list: Vec<String> = engine
1021        .notes
1022        .iter()
1023        .map(|n| {
1024            format!(
1025                "{} {}={} ({})",
1026                source_name(n.source),
1027                n.key,
1028                redact_text(&n.value),
1029                reason_words(&n.reason)
1030            )
1031        })
1032        .collect();
1033    Check::new(
1034        "settings_notes",
1035        Status::Warn,
1036        "",
1037        &[
1038            ("count", &engine.notes.len().to_string()),
1039            ("list", &list.join("; ")),
1040        ],
1041    )
1042}
1043
1044fn settings_file_hash<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Check {
1045    const ID: &str = "settings_file_hash";
1046    let file = engine.env.home.join("settings.local.md");
1047    let shown = file.display().to_string();
1048    match std::fs::read(&file) {
1049        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1050            Check::new(ID, Status::Skip, "", &[("path", &shown)])
1051        }
1052        Err(e) => Check::new(
1053            ID,
1054            Status::Warn,
1055            "",
1056            &[("path", &shown), ("message", &e.to_string())],
1057        ),
1058        Ok(bytes) => {
1059            let hash = hex(&Sha256::digest(&bytes));
1060            let mtime = std::fs::metadata(&file).ok().and_then(|m| mtime_ms(&m));
1061            let mtime_s = mtime.map_or("unknown".to_string(), |m| m.to_string());
1062            Check::new(
1063                ID,
1064                Status::Ok,
1065                "",
1066                &[("path", &shown), ("hash", &hash), ("mtime_ms", &mtime_s)],
1067            )
1068            .details(json!({"path": shown, "sha256": hash, "mtime_ms": mtime}))
1069        }
1070    }
1071}
1072
1073fn resolved_settings<T: Transport, C: Clock>(engine: &Engine<T, C>) -> Value {
1074    let s = &engine.settings;
1075    let mut sources = Map::new();
1076    for (k, v) in &s.sources {
1077        sources.insert((*k).to_string(), json!(source_name(*v)));
1078    }
1079    json!({
1080        "wait_timeout_s": s.wait_timeout_s,
1081        "poll_s": s.poll_s,
1082        "stall_s": s.stall_s,
1083        "max_turn_s": s.max_turn_s,
1084        "call_timeout_s": s.call_timeout_s,
1085        "output_max_bytes": s.output_max_bytes,
1086        "body_max_bytes": s.body_max_bytes,
1087        "wait_max_s": s.wait_max_s,
1088        "opencode_bin": s.opencode_bin.as_ref().map(|p| p.display().to_string()),
1089        "server_url": s.server_url.as_deref().map(redact_text),
1090        "default_model": s.default_model,
1091        "default_agent": s.default_agent,
1092        "permission_policy": s.permission_policy.as_str(),
1093        "permissions_locked": s.permissions_locked,
1094        "lock_source": s.lock_source.map(source_name),
1095        "sources": sources,
1096    })
1097}
1098
1099/// The doctor report: `{"checks":[...], "notes":[...], "settings"?}`.
1100pub async fn doctor_json<T: Transport, C: Clock>(
1101    engine: &Engine<T, C>,
1102    ctx: &DoctorCtx<'_>,
1103    verbose: bool,
1104) -> Value {
1105    // COMPUTE order: ctl_home_override, binary, registration (before anything can start the
1106    // service), server_info, then the rest in id order.
1107    let mut checks = vec![ctl_home_override(ctx)];
1108    let binary = binary_check(engine, ctx);
1109    let registration = registration_check(engine, ctx);
1110    let (server_check, server) = server_info_check(engine).await;
1111    checks.push(binary.clone());
1112    checks.push(registration);
1113    checks.push(server_check);
1114    checks.push(version_range_check(engine, &binary, &server).await);
1115    checks.push(wait_route_check(engine, &server).await);
1116    checks.push(pairing_check(engine, &server).await);
1117    checks.push(lock_source_check(engine));
1118    checks.push(env_vs_user_file(engine, ctx));
1119    checks.push(windows_source_old_check(ctx.bin_dir, cfg!(windows)));
1120    checks.push(launcher_link_check(engine, ctx));
1121    checks.push(server_url_password(engine, ctx));
1122    checks.push(opencode_password_set(ctx));
1123    checks.push(log_check(engine));
1124    checks.push(orphaned_controllers(engine));
1125    checks.push(orphaned_workers(engine, &server).await);
1126    checks.push(settings_notes(engine));
1127    checks.push(settings_file_hash(engine));
1128    // EMIT order: id order, so version_range could read server_info's response.
1129    checks.sort_by_key(|c| CHECK_IDS.iter().position(|i| *i == c.id));
1130    let mut out = json!({
1131        "checks": checks.iter().map(Check::to_json).collect::<Vec<_>>(),
1132        "notes": engine.notes.iter().map(note_json).collect::<Vec<_>>(),
1133    });
1134    if verbose {
1135        out["settings"] = resolved_settings(engine);
1136    }
1137    out
1138}