Skip to main content

forge_workspace/
views.rs

1//! Read-only views surfaced by [`crate::Workspace::list_projects`].
2
3use std::path::PathBuf;
4use std::time::SystemTime;
5
6use crate::target::ProjectKey;
7
8/// One project from the catalog plus its sessions, sorted last-
9/// activity descending. `sessions[0]` is the lead. Empty `sessions`
10/// means the project has no on-disk history yet.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct ProjectView {
13    pub key: ProjectKey,
14    /// The toml `name` field from `forge.toml`. Distinct from `key`,
15    /// which is the canonicalised on-disk project key derived from
16    /// the project's path. Callers wanting to address a project via
17    /// [`crate::SessionTarget::Named`] use this value; callers
18    /// keying a HashMap of in-process Agent handles use [`Self::key`].
19    pub name: String,
20    /// Name of the org this project belongs to (from
21    /// `[[orgs]].name` in `forge.toml`). Drives the org-grouping
22    /// in the Projects pane tree render.
23    pub org: String,
24    /// Filesystem-resolved project root (`~` expanded). This is the
25    /// path callers should hand to filesystem APIs - `cwd_raw` for
26    /// the spawning bucket, `file_index::restart`, the git-context
27    /// watcher, etc. Use [`Self::display_path`] for human-readable
28    /// rendering instead.
29    pub path: PathBuf,
30    /// Human-readable rendering of the project's root path (e.g.
31    /// `~/Projects/forge`, with `~` left in place rather than
32    /// expanded). Display-only - not a path you can `open()`.
33    pub display_path: String,
34    /// Account `display_name`s this project may spawn under, inherited
35    /// from the project's `[[orgs]]` entry. Non-empty (the config
36    /// loader enforces).
37    pub accounts: Vec<String>,
38    /// Fallback `display_name`s inherited from the project's `[[orgs]]`
39    /// entry, alongside `accounts`. Empty when the org names none.
40    pub fallback_accounts: Vec<String>,
41    /// `true` when the project declares `model`. A spawn under a
42    /// project without one is refused, so the launchpad's row needs to
43    /// tell the two reasons no spawn can run apart: no model to match
44    /// on, or no account that could serve it.
45    pub has_model: bool,
46    pub sessions: Vec<SessionView>,
47}
48
49#[cfg(feature = "test-helpers")]
50impl ProjectView {
51    /// Test-only constructor for cross-crate fixtures (a view's
52    /// Projects pane snapshot tests). Behind the `test-helpers`
53    /// Cargo feature to keep test-only construction out of the
54    /// production API.
55    pub fn new_for_test(
56        key: ProjectKey,
57        name: impl Into<String>,
58        display_path: impl Into<String>,
59        sessions: Vec<SessionView>,
60    ) -> Self {
61        let display_path = display_path.into();
62        Self {
63            key,
64            name: name.into(),
65            org: "Test".to_owned(),
66            path: PathBuf::from(&display_path),
67            display_path,
68            accounts: Vec::new(),
69            fallback_accounts: Vec::new(),
70            has_model: true,
71            sessions,
72        }
73    }
74
75    /// Variant of [`Self::new_for_test`] that lets the fixture
76    /// supply an org + accounts list.
77    pub fn new_for_test_with_org(
78        key: ProjectKey,
79        name: impl Into<String>,
80        display_path: impl Into<String>,
81        org: impl Into<String>,
82        accounts: Vec<String>,
83        fallback_accounts: Vec<String>,
84        sessions: Vec<SessionView>,
85    ) -> Self {
86        let display_path = display_path.into();
87        Self {
88            key,
89            name: name.into(),
90            org: org.into(),
91            path: PathBuf::from(&display_path),
92            display_path,
93            accounts,
94            fallback_accounts,
95            has_model: true,
96            sessions,
97        }
98    }
99}
100
101// The account auth classification is returned by the gateway's account
102// state, so it lives in forge-primitives now. Re-exported here so
103// `forge_workspace::AccountAuth` and `crate::views::AccountAuth` keep
104// resolving.
105pub use forge_primitives::account::AccountAuth;
106
107/// One account's place in preflight: what it is called, how far it
108/// has got, and how it authenticates.
109#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110pub struct AccountLoadingRow {
111    /// forge.toml `[[accounts]]` display name.
112    pub display_name: String,
113    pub state: forge_gateway::LoadingState,
114    /// The classified outcome of the last failed probe attempt. What
115    /// lets a bailed row say `unreachable` when the endpoint is simply
116    /// down rather than `auth failed`.
117    pub last_error: Option<forge_gateway::UsageFetchStatus>,
118    /// Remaining hold-down before the pollers re-probe a failed
119    /// account - the server `Retry-After` for a 429, the exponential
120    /// schedule otherwise. `None` when nothing is scheduled.
121    pub retry_after: Option<std::time::Duration>,
122    /// Which repair instruction a bailed row earns.
123    pub auth: AccountAuth,
124    /// A cached usage window is at its cap with the reset still ahead, so a
125    /// spawn would not bind here until then. **Carried without the usage**:
126    /// it is the half of the usage that moves selection, and a client
127    /// comparing two pools needs it to know a frame moved a project row.
128    pub at_cap: bool,
129    /// The rotation is holding the account out after a probe wrote a limit.
130    /// The other selection input a usage move can take with it.
131    pub cooling: bool,
132}
133
134/// The account pool as a view draws it: the rows the home's snapshot carries
135/// and the state an `AccountsChanged` frame pushes.
136///
137/// **One shape for both paths.** The snapshot field and the frame's payload
138/// are built by [`crate::Workspace::account_pool_view`], so a client folding a
139/// frame and a client reading the snapshot cannot disagree (rule 25).
140#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub struct AccountPoolView {
143    pub loading: Vec<AccountLoadingRow>,
144    /// Every account has settled and the inference listener is bound.
145    pub all_loaded: bool,
146    pub gateway: AccountGatewayView,
147    /// What each account has left, one row per configured account.
148    pub usage: Vec<AccountUsageView>,
149}
150
151/// The inference listener's bind state.
152#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub struct AccountGatewayView {
155    pub ready: bool,
156    pub port: u16,
157    /// Set when the listener could not bind its port.
158    pub bind_error: Option<String>,
159}
160
161/// One account's latest usage reading.
162#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub struct AccountUsageView {
165    pub display_name: String,
166    /// The newest reading the poller holds, a rate-limited response's
167    /// windows included, so a cooling account still states its plan.
168    /// `None` until the account reports anything.
169    pub snapshot: Option<forge_primitives::usage::UsageSnapshot>,
170}
171
172/// One session under a project.
173#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
174pub struct SessionView {
175    /// The claude session id the catalog row names, taken from the
176    /// transcript file on disk. A row here is a transcript rather than
177    /// one of forge's slots, so this stays an id - the same exemption
178    /// transcript filenames themselves have.
179    pub session: forge_primitives::SessionId,
180    /// Display label for the session - the title set via the
181    /// session-rename flow if any, otherwise a derivation from the
182    /// session id or first message. Rendered in the Projects pane.
183    pub label: String,
184    /// `true` when an Agent for this session is currently in the
185    /// workspace pool.
186    pub is_open: bool,
187    pub last_activity: Option<SystemTime>,
188}
189
190#[cfg(feature = "test-helpers")]
191impl SessionView {
192    /// Test-only constructor for cross-crate fixtures.
193    pub fn new_for_test(
194        session: forge_primitives::SessionId,
195        label: impl Into<String>,
196        is_open: bool,
197        last_activity: Option<SystemTime>,
198    ) -> Self {
199        Self { session, label: label.into(), is_open, last_activity }
200    }
201}