Skip to main content

gwm/
json_api.rs

1//! Stable, machine-readable JSON surface shared by the `--format=json`
2//! CLI flags (issue #38, phase 1) and the daemon's JSON-RPC methods
3//! (phase 2).
4//!
5//! The DTOs here are deliberately decoupled from the internal
6//! [`crate::worktree::WorktreeInfo`] / [`crate::doctor::DoctorReport`]
7//! types. Those structs carry TUI-runtime baggage (loaded GitHub issue /
8//! PR state, cached branch age as a `Duration`, the `BranchLink` graph)
9//! whose shape churns as the TUI evolves. Pinning the documented schema
10//! (see `docs/schema/`) to a dedicated set of `Serialize` DTOs means a
11//! refactor of `WorktreeInfo` can't silently break a downstream editor
12//! plugin. Conversions are one-directional (`From<&Internal>`); the JSON
13//! surface is output-only.
14//!
15//! Key convention: `snake_case`, matching the hand-built
16//! `print_status_json` in [`crate::cli`].
17
18use crate::doctor::{CheckStatus, DoctorReport};
19use crate::error::Result;
20use crate::worktree::{self, BranchStatus, WorktreeInfo};
21use serde::{Deserialize, Serialize};
22
23/// Working-tree + upstream status, the stable projection of
24/// [`BranchStatus`].
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct JsonStatus {
27  pub is_dirty: bool,
28  pub has_upstream: bool,
29  pub ahead: usize,
30  pub behind: usize,
31  /// Status couldn't be computed (detached HEAD, unborn branch).
32  pub unknown: bool,
33}
34
35impl From<&BranchStatus> for JsonStatus {
36  fn from(s: &BranchStatus) -> Self {
37    Self {
38      is_dirty: s.is_dirty,
39      has_upstream: s.has_upstream,
40      ahead: s.ahead,
41      behind: s.behind,
42      unknown: s.unknown,
43    }
44  }
45}
46
47/// One worktree as exposed to scripting / editor integrations. Mirrors
48/// the columns of `gwm list` plus the machine-only fields a consumer
49/// needs (absolute `path`, raw `age_seconds`, linked issue/PR numbers).
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct JsonWorktree {
52  /// Display name — the basename of the worktree directory.
53  pub name: String,
54  /// Internal git worktree id (`.git/worktrees/<id>`); diverges from
55  /// `name` after a `git worktree move`.
56  pub id: String,
57  /// Absolute path to the worktree working directory.
58  pub path: String,
59  pub branch: Option<String>,
60  /// Full HEAD commit oid (40-char hex), when resolvable. A machine
61  /// consumer gets the exact oid for comparison; truncate client-side if a
62  /// short form is wanted.
63  pub head: Option<String>,
64  pub is_main: bool,
65  pub is_locked: bool,
66  pub is_prunable: bool,
67  pub status: JsonStatus,
68  /// Branch age relative to the trunk baseline, in whole seconds.
69  /// `null` for trunk branches and unresolvable repos.
70  pub age_seconds: Option<u64>,
71  /// Linked issue number (branch-name inferred or explicit), if any.
72  pub issue: Option<u64>,
73  /// Linked PR number (inferred, explicit, or auto-detected), if any.
74  pub pr: Option<u64>,
75  /// Agent sessions matched to this worktree (issue #408). **Experimental
76  /// tier** — additive, omitted entirely (never `null`) when no session
77  /// matched, so pre-#408 payloads are byte-identical. See
78  /// `docs/schema/README.md` for the tier rules.
79  #[serde(default, skip_serializing_if = "Option::is_none")]
80  pub agents: Option<JsonWorktreeAgents>,
81}
82
83/// The agent-session summary of one worktree row (issue #408).
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct JsonWorktreeAgents {
86  /// The most recently active session — what compact surfaces display.
87  pub top: JsonAgentSession,
88  /// Every matched session, most recent first.
89  pub sessions: Vec<JsonAgentSession>,
90}
91
92/// One detected agent session on the wire (issue #408).
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct JsonAgentSession {
95  /// Stable lowercase agent name: `claude` | `codex` | `opencode` | `vibe`.
96  pub kind: String,
97  /// `active` | `idle`.
98  pub freshness: String,
99  /// Last artefact activity, epoch seconds UTC.
100  pub last_activity: u64,
101  /// Backend-stable session identifier.
102  pub id: String,
103  /// Human-readable session name when the artefacts carry one (first user
104  /// prompt or recorded title). Omitted when unavailable.
105  #[serde(default, skip_serializing_if = "Option::is_none")]
106  pub name: Option<String>,
107}
108
109impl JsonWorktreeAgents {
110  /// Wire shape of a detection summary, `None` when no session matched —
111  /// feeding `skip_serializing_if` so empty rows stay byte-identical.
112  pub fn from_summary(agents: &crate::agent_sessions::WorktreeAgents, now: std::time::SystemTime) -> Option<Self> {
113    let to_wire = |s: &crate::agent_sessions::AgentSession| JsonAgentSession {
114      kind: s.kind.display().to_string(),
115      freshness: match crate::agent_sessions::Freshness::classify(s.last_activity, s.ended, now) {
116        crate::agent_sessions::Freshness::Active => "active".to_string(),
117        crate::agent_sessions::Freshness::Idle => "idle".to_string(),
118      },
119      last_activity: s
120        .last_activity
121        .duration_since(std::time::SystemTime::UNIX_EPOCH)
122        .map(|d| d.as_secs())
123        .unwrap_or(0),
124      id: s.id.clone(),
125      name: s.name.clone(),
126    };
127    let top = agents.top()?;
128    Some(Self {
129      top: to_wire(top),
130      sessions: agents.sessions.iter().map(to_wire).collect(),
131    })
132  }
133}
134
135impl From<&WorktreeInfo> for JsonWorktree {
136  fn from(w: &WorktreeInfo) -> Self {
137    Self {
138      name: w.name.clone(),
139      id: w.id.clone(),
140      // The plain lossy absolute path: it is the PUBLIC schema value
141      // consumers open and compare, so it must never grow disambiguation
142      // suffixes (Codex review round U undoing round T's key reuse) —
143      // agent association uses a separate lossless INTERNAL key derived
144      // from the caller-kept real `PathBuf`s instead.
145      path: w.path.to_string_lossy().into_owned(),
146      branch: w.branch.clone(),
147      head: w.head.clone(),
148      is_main: w.is_main,
149      is_locked: w.is_locked,
150      is_prunable: w.is_prunable,
151      status: JsonStatus::from(&w.status),
152      age_seconds: w.age.map(|d| d.as_secs()),
153      issue: w.link.issue,
154      pr: w.link.pr,
155      // Filled by the list assembly when detection ran (issue #408); a bare
156      // conversion carries no session info.
157      agents: None,
158    }
159  }
160}
161
162/// The `{ name, path, branch }` triple returned by `gwm path --format=json`.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164pub struct JsonPath {
165  pub name: String,
166  pub path: String,
167  pub branch: Option<String>,
168}
169
170impl From<&WorktreeInfo> for JsonPath {
171  fn from(w: &WorktreeInfo) -> Self {
172    Self {
173      name: w.name.clone(),
174      path: w.path.to_string_lossy().into_owned(),
175      branch: w.branch.clone(),
176    }
177  }
178}
179
180/// Stable lowercase string for a [`CheckStatus`], used as the `status`
181/// field of [`JsonCheck`] and the `severity` of [`JsonDoctorReport`].
182pub fn check_status_str(status: &CheckStatus) -> &'static str {
183  match status {
184    CheckStatus::Ok => "ok",
185    CheckStatus::Warning => "warning",
186    CheckStatus::Failed => "failed",
187  }
188}
189
190/// One diagnostic check, the stable projection of [`crate::doctor::Check`].
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
192pub struct JsonCheck {
193  pub name: String,
194  /// `"ok"`, `"warning"`, or `"failed"`.
195  pub status: String,
196  pub detail: String,
197  pub fix_hint: Option<String>,
198}
199
200/// A full doctor run, carrying the per-check list plus the aggregate
201/// `severity` and the process `exit_code` (`0`/`1`/`2`) so a consumer
202/// doesn't have to re-derive them.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204pub struct JsonDoctorReport {
205  pub checks: Vec<JsonCheck>,
206  /// Highest severity present: `"ok"`, `"warning"`, or `"failed"`.
207  pub severity: String,
208  pub exit_code: i32,
209}
210
211impl From<&DoctorReport> for JsonDoctorReport {
212  fn from(r: &DoctorReport) -> Self {
213    Self {
214      checks: r
215        .checks
216        .iter()
217        .map(|c| JsonCheck {
218          name: c.name.clone(),
219          status: check_status_str(&c.status).to_string(),
220          detail: c.detail.clone(),
221          fix_hint: c.fix_hint.clone(),
222        })
223        .collect(),
224      severity: check_status_str(&r.severity()).to_string(),
225      exit_code: r.exit_code(),
226    }
227  }
228}
229
230/// Build the stable JSON worktree list for `repo`. Shared by
231/// `gwm list --format=json` and the daemon's `list` RPC method so both
232/// surfaces stay byte-identical.
233pub fn worktrees(repo: &git2::Repository) -> Result<Vec<JsonWorktree>> {
234  let trees = worktree::list(repo)?;
235  let mut rows: Vec<JsonWorktree> = trees.iter().map(JsonWorktree::from).collect();
236  let reals: Vec<std::path::PathBuf> = trees.iter().map(|w| w.path.clone()).collect();
237  let pins = agent_pins_for_rows(repo, &trees);
238  attach_agents(&mut rows, &reals, &pins);
239  Ok(rows)
240}
241
242/// Manual agent pins for already-built rows: `(path key, session id)` pairs
243/// read from each row's branch config (issue #408 US4). Rows without a
244/// branch (detached) cannot carry a pin.
245pub fn agent_pins_for_rows(repo: &git2::Repository, trees: &[crate::worktree::WorktreeInfo]) -> Vec<(String, String)> {
246  trees
247    .iter()
248    .flat_map(|w| {
249      let pins = crate::github::pinnable_branch(w.branch.as_deref())
250        .map(|branch| crate::github::agent_pins(repo, branch).unwrap_or_default())
251        .unwrap_or_default();
252      // Keyed by the lossless display key — the INTERNAL association key
253      // shared with `attach_agents` (round U), never the public path.
254      let key = crate::agent_sessions::path_display_key(&w.path);
255      pins.into_iter().map(move |sid| (key.clone(), sid))
256    })
257    .collect()
258}
259
260/// Populate the experimental `agents` field on already-built rows (issue
261/// #408): one detection pass over the whole set, keyed back by `path`, with
262/// manual `pins` overlaid. The single shared implementation for every
263/// surface (CLI list, daemon, workspace rows) so they cannot drift. No home
264/// directory → no-op (FR-009).
265///
266/// Workspace callers open each row's owning repo to build `pins` (Codex
267/// review round I) — this pass itself stays repo-agnostic.
268pub fn attach_agents(rows: &mut [JsonWorktree], reals: &[std::path::PathBuf], pins: &[(String, String)]) {
269  attach_agents_inner(rows, reals, pins, false);
270}
271
272/// [`attach_agents`] variant that also returns the raw session pool, so
273/// `gwm agents` can list the sessions no worktree matched — precisely the
274/// ones worth attaching manually (Codex review round C). Split from the
275/// plain call because the pool costs the Claude foreign-dir sweep (round
276/// F): `gwm list` and daemon polls must not pay it.
277pub fn attach_agents_with_pool(
278  rows: &mut [JsonWorktree],
279  reals: &[std::path::PathBuf],
280  pins: &[(String, String)],
281) -> Vec<crate::agent_sessions::AgentSession> {
282  attach_agents_inner(rows, reals, pins, true)
283}
284
285fn attach_agents_inner(
286  rows: &mut [JsonWorktree],
287  reals: &[std::path::PathBuf],
288  pins: &[(String, String)],
289  want_pool: bool,
290) -> Vec<crate::agent_sessions::AgentSession> {
291  let Some(home) = crate::agent_sessions::agents_home() else {
292    return Vec::new();
293  };
294  let now = std::time::SystemTime::now();
295  // The association keys are lossless display keys derived from the
296  // ORIGINAL PathBufs the caller kept (rows and reals are parallel) —
297  // never the public `row.path`, which stays the plain lossy absolute
298  // path of the schema and could collide for non-UTF-8 worktrees
299  // (Codex review rounds T + U).
300  debug_assert_eq!(rows.len(), reals.len());
301  let keyed: Vec<(String, std::path::PathBuf)> = reals
302    .iter()
303    .map(|p| (crate::agent_sessions::path_display_key(p), p.clone()))
304    .collect();
305  let (summary, pool) = detect_cached(&home, &keyed, pins, now, want_pool);
306  for (row, real) in rows.iter_mut().zip(reals) {
307    row.agents = summary
308      .get(&crate::agent_sessions::path_display_key(real))
309      .and_then(|a| JsonWorktreeAgents::from_summary(a, now));
310  }
311  pool
312}
313
314/// Detection result cache for the daemon's poll loop (Codex review round A):
315/// `subscribe` consumers make the daemon re-list every poll tick (1 s by
316/// default, once per subscriber), and re-walking the Codex/opencode/Vibe
317/// stores each time is real disk churn. Same inputs within the TTL reuse the
318/// last summary — the TUI's own 30 s re-detection cadence, applied here.
319/// ponytail: one process-global slot guarded by a Mutex; per-input LRU only
320/// if a real multi-repo daemon setup ever needs it.
321/// `want_pool` selects the detection depth (round F): `false` = summary
322/// only, matched-only Claude scan, empty pool returned; `true` = full
323/// sweep + raw pool. A cached full detection serves BOTH shapes (the
324/// summary is identical — swept sessions never summarize); a cached
325/// summary-only entry cannot serve a pool request and is recomputed.
326fn detect_cached(
327  home: &std::path::Path,
328  keyed: &[(String, std::path::PathBuf)],
329  pins: &[(String, String)],
330  now: std::time::SystemTime,
331  want_pool: bool,
332) -> (
333  std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
334  Vec<crate::agent_sessions::AgentSession>,
335) {
336  const TTL: std::time::Duration = std::time::Duration::from_secs(30);
337  type CacheKey = (std::path::PathBuf, Vec<(String, String)>, Vec<String>);
338  type Detection = (
339    std::collections::BTreeMap<String, crate::agent_sessions::WorktreeAgents>,
340    Vec<crate::agent_sessions::AgentSession>,
341  );
342  type CacheSlot = Option<(std::time::Instant, CacheKey, bool, Detection)>;
343  static CACHE: std::sync::Mutex<CacheSlot> = std::sync::Mutex::new(None);
344
345  let key: CacheKey = (
346    home.to_path_buf(),
347    pins.to_vec(),
348    keyed.iter().map(|(k, _)| k.clone()).collect(),
349  );
350  // A poisoned mutex here would mean a panic mid-detection; recover by
351  // recomputing rather than propagating the poison.
352  let mut slot = CACHE.lock().unwrap_or_else(|e| e.into_inner());
353  if let Some((at, cached_key, has_pool, detection)) = slot.as_ref() {
354    if *cached_key == key && at.elapsed() < TTL && (*has_pool || !want_pool) {
355      return detection.clone();
356    }
357  }
358  let detection = if want_pool {
359    crate::agent_sessions::detect_with_sessions(home, keyed, pins, now)
360  } else {
361    (crate::agent_sessions::detect_all(home, keyed, pins, now), Vec::new())
362  };
363  *slot = Some((std::time::Instant::now(), key, want_pool, detection.clone()));
364  detection
365}