1use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7use tokio::process::Command;
8
9use crate::engine::runner::commit::When;
10use crate::engine::runner::{git_ops, worktree};
11use crate::store::TaskStore;
12
13const MAX_TEXT: usize = 500;
15const KEEP: usize = 5;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Report {
21 pub worktree: String,
22 pub done: String,
23 pub left: String,
24 pub state: String,
25 pub cli_committed: bool,
26 #[serde(default)]
27 pub cli_error: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct Record {
32 pub node: String,
33 pub ts: String,
34 pub branch: String,
35 pub commit: String,
36 pub committed_by: String,
38 pub pushed_by: String,
40 #[serde(default)]
41 pub error: Option<String>,
42 pub done: String,
43 pub left: String,
44 pub state: String,
45}
46
47fn cut(s: &str) -> String {
48 s.chars().take(MAX_TEXT).collect()
49}
50
51fn key(task_id: i64) -> String {
52 format!("progress:{task_id}")
53}
54
55pub async fn list(store: &TaskStore, task_id: i64) -> anyhow::Result<Vec<Record>> {
56 match store.kv_get(&key(task_id)).await? {
57 Some(json) => Ok(serde_json::from_str(&json)?),
58 None => Ok(Vec::new()),
59 }
60}
61
62async fn git_out(dir: &Path, args: &[&str]) -> Option<String> {
63 let out = Command::new("git")
64 .args(args)
65 .current_dir(dir)
66 .output()
67 .await
68 .ok()?;
69 out.status
70 .success()
71 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
72}
73
74pub async fn handle(store: &TaskStore, node: &str, report: Report) -> anyhow::Result<Record> {
77 let task_id = store
78 .resolve_id_by_worktree(&report.worktree)
79 .await?
80 .ok_or_else(|| anyhow::anyhow!("{} is not the worktree of a task", report.worktree))?;
81 let task = store.get(task_id).await?;
82 let dir = Path::new(&report.worktree);
83 let previous = list(store, task_id).await?;
84 let mut errors: Vec<String> = report.cli_error.iter().cloned().collect();
85
86 let head_before = git_out(dir, &["rev-parse", "HEAD"])
87 .await
88 .unwrap_or_default();
89 let mut committed_by = if report.cli_committed { "cli" } else { "none" };
90 let ext = task.external_id.clone().unwrap_or_default();
91 match git_ops::auto_commit(
92 dir,
93 &ext,
94 &report.done,
95 "orch",
96 u32::try_from(task.attempts).unwrap_or(0),
97 When::Tested,
98 )
99 .await
100 {
101 Ok(true) => committed_by = "orch",
102 Ok(false) => {}
103 Err(e) => errors.push(format!("commit: {e:#}")),
104 }
105
106 if committed_by == "none"
107 && previous
108 .last()
109 .is_some_and(|last| !head_before.is_empty() && last.commit != head_before)
110 {
111 committed_by = "agent";
112 }
113
114 let branch = git_ops::get_current_branch(dir).await;
115 let commit = git_out(dir, &["rev-parse", "HEAD"])
116 .await
117 .unwrap_or_default();
118 let remote = git_out(
119 dir,
120 &["rev-parse", &format!("refs/remotes/origin/{branch}")],
121 )
122 .await;
123 let pushed_by = if remote.as_deref() == Some(commit.as_str()) {
124 "agent"
125 } else {
126 let default_branch = worktree::detect_default_branch(dir).await;
127 match git_ops::push_branch(dir, &branch, &default_branch).await {
128 Ok(true) => "orch",
129 Ok(false) => "none",
130 Err(e) => {
131 errors.push(format!("push: {e:#}"));
132 "none"
133 }
134 }
135 };
136
137 let record = Record {
138 node: node.to_string(),
139 ts: super::now_stamp(),
140 branch,
141 commit,
142 committed_by: committed_by.to_string(),
143 pushed_by: pushed_by.to_string(),
144 error: (!errors.is_empty()).then(|| cut(&errors.join("; "))),
145 done: cut(&report.done),
146 left: cut(&report.left),
147 state: cut(&report.state),
148 };
149 let mut records = previous;
150 records.push(record.clone());
151 let skip = records.len().saturating_sub(KEEP);
152 let records: Vec<Record> = records.into_iter().skip(skip).collect();
153 store
154 .kv_set(&key(task_id), &serde_json::to_string(&records)?)
155 .await?;
156 Ok(record)
157}