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}