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