Skip to content
353 linesCodeBlameRaw
1//! `g1t session`: one run of an agent's harness, recorded as it works.
2//!
3//! The session a working copy is in is kept in `<git dir>/g1t/session`,
4//! where `<git dir>` is the worktree's own (`git rev-parse
5//! --absolute-git-dir`), so each worktree has its own; `G1T_SESSION`
6//! overrides it. `g1t work claim` attaches its claim to it.
7
8use serde_json::{Value, json};
9use std::io::Read;
10use std::path::PathBuf;
11
12use crate::cli::{
13 SessionAttachArgs, SessionCommand, SessionEndArgs, SessionListArgs, SessionLogArgs,
14 SessionStartArgs, SessionState, SessionViewArgs,
15};
16use crate::client::Client;
17use crate::context::Ctx;
18use crate::error::{Error, Result};
19use crate::output::{Cell, Output, ago, s};
20use crate::repo::Repo;
21
22pub fn run(ctx: &Ctx, command: SessionCommand) -> Result<()> {
23 match command {
24 SessionCommand::Start(args) => start(ctx, args),
25 SessionCommand::Heartbeat => heartbeat(ctx),
26 SessionCommand::Log(args) => log(ctx, args),
27 SessionCommand::Attach(args) => attach(ctx, args),
28 SessionCommand::End(args) => end(ctx, args),
29 SessionCommand::List(args) => list(ctx, args),
30 SessionCommand::View(args) => view(ctx, args),
31 }
32}
33
34/// Where this working copy's session is kept: `<git dir>/g1t/session`.
35fn file() -> Option<PathBuf> {
36 crate::git::run(&["rev-parse", "--absolute-git-dir"])
37 .ok()
38 .filter(|dir| !dir.is_empty())
39 .map(|dir| PathBuf::from(dir).join("g1t").join("session"))
40}
41
42/// The session id in `G1T_SESSION`, else the one kept in `file`.
43fn chosen(env: Option<String>, file: Option<PathBuf>) -> Option<String> {
44 env.map(|id| id.trim().to_owned())
45 .filter(|id| !id.is_empty())
46 .or_else(|| {
47 let text = std::fs::read_to_string(file?).ok()?;
48 let id = text.lines().next()?.trim().to_owned();
49 (!id.is_empty()).then_some(id)
50 })
51}
52
53/// The current session's id, if there is one.
54pub fn current() -> Option<String> {
55 chosen(std::env::var("G1T_SESSION").ok(), file())
56}
57
58fn required() -> Result<String> {
59 current().ok_or_else(|| {
60 Error::usage("No session here: start one with `g1t session start`, or set G1T_SESSION.")
61 })
62}
63
64fn remember(id: &str) -> Result<()> {
65 let Some(path) = file() else {
66 // Outside a working copy there is nowhere to keep it; G1T_SESSION does.
67 return Ok(());
68 };
69 if let Some(dir) = path.parent() {
70 std::fs::create_dir_all(dir)?;
71 }
72 std::fs::write(&path, format!("{id}\n"))?;
73 Ok(())
74}
75
76/// Forgets the kept session, when it is `id`.
77fn forget(id: &str) {
78 if let Some(path) = file()
79 && chosen(None, Some(path.clone())).as_deref() == Some(id)
80 {
81 let _ = std::fs::remove_file(path);
82 }
83}
84
85/// The session in an answer that may wrap it (`{session, entries}`).
86fn session_of(found: Value) -> Value {
87 super::unwrap_key(found, "session")
88}
89
90fn start(ctx: &Ctx, args: SessionStartArgs) -> Result<()> {
91 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
92 let branch = args.branch.clone().or_else(crate::git::current_branch);
93 let head = crate::git::run(&["rev-parse", "HEAD"]).ok().filter(|h| !h.is_empty());
94 let found = client
95 .op("start_session")
96 .repo(&repo)
97 .field("harness", args.harness.name())
98 .field_opt("harness_session_id", args.harness_session_id.clone())
99 .field_opt("issue", args.issue)
100 .field_opt("pull", args.pull)
101 .field_opt("branch", branch)
102 .field_opt("title", args.title.clone())
103 .field_opt("head_commit", head)
104 .send()?;
105 let session = session_of(found);
106 let id = s(&session, "id").to_owned();
107 if id.is_empty() {
108 return Err(Error::failed("The API did not say which session it started."));
109 }
110 remember(&id)?;
111 if ctx.out.structured() {
112 return ctx.out.emit(&session);
113 }
114 println!("{id}");
115 Ok(())
116}
117
118fn heartbeat(ctx: &Ctx) -> Result<()> {
119 let id = required()?;
120 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
121 let found = client.op("update_session").repo(&repo).arg("id", &id).send()?;
122 if ctx.out.structured() {
123 return ctx.out.emit(&session_of(found));
124 }
125 Ok(())
126}
127
128fn log(ctx: &Ctx, args: SessionLogArgs) -> Result<()> {
129 let id = required()?;
130 let text = if args.text == "-" {
131 let mut text = String::new();
132 std::io::stdin().read_to_string(&mut text)?;
133 text
134 } else {
135 args.text.clone()
136 };
137 if text.trim().is_empty() {
138 return Err(Error::usage("Nothing to record: give the text."));
139 }
140 let mut entry = json!({ "kind": args.kind.name(), "text": text.trim_end() });
141 if let Some(tool) = &args.tool {
142 entry["tool"] = json!(tool);
143 }
144 if let Some(key) = &args.key {
145 entry["key"] = json!(key);
146 }
147 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
148 let found = client
149 .op("record_session_entries")
150 .repo(&repo)
151 .arg("id", &id)
152 .field("entries", json!([entry]))
153 .send()?;
154 if ctx.out.structured() {
155 return ctx.out.emit(&found);
156 }
157 Ok(())
158}
159
160/// Where a session is: `#12`, `pull #14`, its branch.
161fn place(session: &Value) -> String {
162 let mut parts = Vec::new();
163 if let Some(issue) = session["issue"].as_u64() {
164 parts.push(format!("#{issue}"));
165 }
166 if let Some(pull) = session["pull"].as_u64() {
167 parts.push(format!("pull #{pull}"));
168 }
169 if let Some(branch) = session["branch"].as_str().filter(|b| !b.is_empty()) {
170 parts.push(branch.to_owned());
171 }
172 parts.join(", ")
173}
174
175fn attach(ctx: &Ctx, args: SessionAttachArgs) -> Result<()> {
176 let id = required()?;
177 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
178 let found = client
179 .op("update_session")
180 .repo(&repo)
181 .arg("id", &id)
182 .field_opt("issue", args.issue)
183 .field_opt("pull", args.pull)
184 .field_opt("branch", args.branch.clone())
185 .field_opt("head_commit", args.head_commit.clone())
186 .send()?;
187 let session = session_of(found);
188 if ctx.out.structured() {
189 return ctx.out.emit(&session);
190 }
191 eprintln!("Session {id} is on {}.", place(&session));
192 Ok(())
193}
194
195fn end(ctx: &Ctx, args: SessionEndArgs) -> Result<()> {
196 let id = required()?;
197 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
198 let found = client
199 .op("end_session")
200 .repo(&repo)
201 .arg("id", &id)
202 .field_opt("reason", args.reason.clone())
203 .send()?;
204 forget(&id);
205 if ctx.out.structured() {
206 return ctx.out.emit(&session_of(found));
207 }
208 eprintln!("Ended session {id}.");
209 Ok(())
210}
211
212pub fn fetch_list(
213 client: &Client,
214 repo: &Repo,
215 state: Option<&str>,
216 issue: Option<u64>,
217 pull: Option<u64>,
218) -> Result<Vec<Value>> {
219 let found = client
220 .op("list_sessions")
221 .repo(repo)
222 .query_opt("state", state)
223 .query_opt("issue", issue)
224 .query_opt("pull", pull)
225 .send()?;
226 Ok(super::items(found, "sessions"))
227}
228
229fn list(ctx: &Ctx, args: SessionListArgs) -> Result<()> {
230 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
231 let state = args.state.map(|state| match state {
232 SessionState::Active => "active",
233 SessionState::Idle => "idle",
234 SessionState::Ended => "ended",
235 });
236 let mut sessions = fetch_list(&client, &repo, state, args.issue, args.pull)?;
237 sessions.truncate(args.limit);
238 if ctx.out.structured() {
239 return ctx.out.emit(&Value::Array(sessions));
240 }
241 if sessions.is_empty() {
242 eprintln!("No {} sessions in {repo}.", state.unwrap_or("matching"));
243 return Ok(());
244 }
245 print_list(&ctx.out, &sessions, current().as_deref())
246}
247
248pub fn print_list(out: &Output, sessions: &[Value], mine: Option<&str>) -> Result<()> {
249 let rows: Vec<Vec<Cell>> = sessions
250 .iter()
251 .map(|session| {
252 let id = s(session, "id");
253 let marked = if Some(id) == mine { format!("{id} *") } else { id.to_owned() };
254 vec![
255 Cell::new(marked),
256 Cell::styled(s(session, "state"), out.state(s(session, "state"))),
257 Cell::new(super::login_of(session)),
258 Cell::new(s(session, "harness")),
259 Cell::new(place(session)),
260 Cell::styled(ago(s(session, "last_seen_at")), out.dim(&ago(s(session, "last_seen_at")))),
261 ]
262 })
263 .collect();
264 out.table(&["session", "state", "who", "harness", "on", "last seen"], &rows)
265}
266
267fn view(ctx: &Ctx, args: SessionViewArgs) -> Result<()> {
268 let id = match args.id {
269 Some(id) => id,
270 None => required()?,
271 };
272 let (repo, client) = super::coordination_client(ctx, super::QUICK)?;
273 let found = client.op("get_session").repo(&repo).arg("id", &id).send()?;
274 let entries = found
275 .get("entries")
276 .cloned()
277 .unwrap_or_else(|| json!([]));
278 let mut session = session_of(found);
279 if let Some(map) = session.as_object_mut() {
280 map.insert("entries".into(), entries);
281 }
282 if ctx.out.structured() {
283 return ctx.out.emit(&session);
284 }
285 let out = &ctx.out;
286 let title = s(&session, "title");
287 println!(
288 "{} {}",
289 out.bold(if title.is_empty() { s(&session, "id") } else { title }),
290 out.dim(&format!("{repo} {}", s(&session, "id")))
291 );
292 let mut facts = vec![out.state(s(&session, "state"))];
293 let who = super::login_of(&session);
294 if !who.is_empty() {
295 facts.push(format!("{who} with {}", s(&session, "harness")));
296 }
297 facts.push(format!("started {}", ago(s(&session, "started_at"))));
298 if let Some(seen) = session["last_seen_at"].as_str() {
299 facts.push(format!("last seen {}", ago(seen)));
300 }
301 println!("{}", facts.join(" · "));
302 let on = place(&session);
303 if !on.is_empty() {
304 println!("{} {on}", out.dim("On:"));
305 }
306 if let Some(head) = session["head_commit"].as_str() {
307 println!("{} {}", out.dim("Head:"), head.get(..12).unwrap_or(head));
308 }
309 if let Some(reason) = session["end_reason"].as_str() {
310 println!("{} {reason}", out.dim("Ended:"));
311 }
312 let entries = session["entries"].as_array().cloned().unwrap_or_default();
313 if !entries.is_empty() {
314 println!();
315 }
316 for entry in &entries {
317 let kind = s(entry, "kind");
318 let label = match entry["tool"].as_str() {
319 Some(tool) => format!("{kind} {tool}"),
320 None => kind.to_owned(),
321 };
322 let text = s(entry, "text").trim();
323 let first = text.lines().next().unwrap_or("");
324 println!("{} {first}", out.dim(&format!("{label:>10}")));
325 }
326 Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn the_environment_overrides_the_kept_session() {
335 let dir = std::env::temp_dir().join(format!("g1t-session-{}", std::process::id()));
336 std::fs::create_dir_all(&dir).unwrap();
337 let path = dir.join("session");
338 std::fs::write(&path, "ses_kept\n").unwrap();
339 assert_eq!(chosen(None, Some(path.clone())).as_deref(), Some("ses_kept"));
340 assert_eq!(chosen(Some("ses_env".into()), Some(path.clone())).as_deref(), Some("ses_env"));
341 assert_eq!(chosen(Some(" ".into()), Some(path.clone())).as_deref(), Some("ses_kept"));
342 std::fs::write(&path, "\n").unwrap();
343 assert_eq!(chosen(None, Some(path)).as_deref(), None);
344 assert_eq!(chosen(None, None), None);
345 let _ = std::fs::remove_dir_all(dir);
346 }
347
348 #[test]
349 fn a_session_says_where_it_is() {
350 assert_eq!(place(&json!({ "issue": 12, "pull": 14, "branch": "fix-x" })), "#12, pull #14, fix-x");
351 assert_eq!(place(&json!({ "issue": null, "branch": "" })), "");
352 }
353}