chant/operations/
finalize.rs1use anyhow::{Context, Result};
6
7use crate::config::Config;
8use crate::operations::commits::{
9 detect_agent_in_commit, get_commits_for_spec, get_commits_for_spec_allow_no_commits,
10 get_commits_for_spec_with_branch, get_commits_for_spec_with_branch_allow_no_commits,
11};
12use crate::operations::model::get_model_name;
13use crate::repository::spec_repository::{FileSpecRepository, SpecRepository};
14use crate::spec::{Spec, SpecStatus, TransitionBuilder};
15use crate::worktree;
16
17#[derive(Debug, Clone, Default)]
19pub struct FinalizeOptions {
20 pub allow_no_commits: bool,
22 pub commits: Option<Vec<String>>,
24 pub force: bool,
26}
27
28pub fn finalize_spec(
40 spec: &mut Spec,
41 spec_repo: &FileSpecRepository,
42 config: &Config,
43 all_specs: &[Spec],
44 options: FinalizeOptions,
45) -> Result<()> {
46 use crate::spec;
47
48 if let Some(worktree_path) = worktree::get_active_worktree(&spec.id, None) {
50 if worktree::has_uncommitted_changes(&worktree_path)? {
51 anyhow::bail!(
52 "Cannot finalize: uncommitted changes in worktree. Commit your changes first.\nWorktree: {}",
53 worktree_path.display()
54 );
55 }
56 }
57
58 let incomplete_members = spec::get_incomplete_members(&spec.id, all_specs);
60 if !incomplete_members.is_empty() {
61 anyhow::bail!(
62 "Cannot complete driver spec '{}' while {} member spec(s) are incomplete: {}",
63 spec.id,
64 incomplete_members.len(),
65 incomplete_members.join(", ")
66 );
67 }
68
69 if !options.force && !has_agent_log(&spec.id) {
71 anyhow::bail!(
72 "Cannot mark spec as completed: no agent execution log found. \
73 Use force parameter to override."
74 );
75 }
76
77 let commits = match options.commits {
79 Some(c) => c,
80 None => {
81 let spec_branch = spec.frontmatter.branch.as_deref();
83 if spec_branch.is_some() && !options.allow_no_commits {
84 get_commits_for_spec_with_branch(&spec.id, spec_branch)?
85 } else if options.allow_no_commits {
86 get_commits_for_spec_allow_no_commits(&spec.id)?
87 } else if let Some(branch) = spec_branch {
88 get_commits_for_spec_with_branch_allow_no_commits(&spec.id, Some(branch))?
89 } else {
90 get_commits_for_spec(&spec.id)?
91 }
92 }
93 };
94
95 if !options.allow_no_commits && commits.is_empty() {
97 anyhow::bail!(
98 "Cannot finalize spec '{}': no commits found. Create commits or use --allow-no-commits",
99 spec.id
100 );
101 }
102
103 if config.approval.require_approval_for_agent_work {
105 check_and_set_agent_approval(spec, &commits, config)?;
106 }
107
108 let unchecked_count = spec.count_unchecked_checkboxes();
110 if unchecked_count > 0 {
111 anyhow::bail!(
112 "Cannot finalize spec '{}': {} acceptance criteria remain unchecked",
113 spec.id,
114 unchecked_count
115 );
116 }
117
118 TransitionBuilder::new(spec)
121 .force()
122 .to(SpecStatus::Completed)
123 .context("Failed to transition spec to Completed status")?;
124
125 spec.frontmatter.commits = if commits.is_empty() {
126 None
127 } else {
128 Some(commits)
129 };
130 spec.frontmatter.completed_at = Some(crate::utc_now_iso());
131 spec.frontmatter.model = get_model_name(Some(config));
132
133 spec_repo
135 .save(spec)
136 .context("Failed to save finalized spec")?;
137
138 anyhow::ensure!(
140 spec.frontmatter.status == SpecStatus::Completed,
141 "Status was not set to Completed after finalization"
142 );
143
144 let completed_at = spec
146 .frontmatter
147 .completed_at
148 .as_ref()
149 .ok_or_else(|| anyhow::anyhow!("completed_at timestamp was not set"))?;
150
151 chrono::DateTime::parse_from_rfc3339(completed_at).with_context(|| {
153 format!(
154 "completed_at must be valid ISO 8601 format, got: {}",
155 completed_at
156 )
157 })?;
158
159 let saved_spec = spec_repo
161 .load(&spec.id)
162 .context("Failed to reload spec from disk to verify persistence")?;
163
164 anyhow::ensure!(
165 saved_spec.frontmatter.status == SpecStatus::Completed,
166 "Persisted spec status is not Completed - save may have failed"
167 );
168
169 anyhow::ensure!(
170 saved_spec.frontmatter.completed_at.is_some(),
171 "Persisted spec is missing completed_at - save may have failed"
172 );
173
174 match (&spec.frontmatter.commits, &saved_spec.frontmatter.commits) {
176 (Some(mem_commits), Some(saved_commits)) => {
177 anyhow::ensure!(
178 mem_commits == saved_commits,
179 "Persisted commits don't match memory - save may have failed"
180 );
181 }
182 (None, None) => {
183 }
185 _ => {
186 anyhow::bail!("Persisted commits don't match memory - save may have failed");
187 }
188 }
189
190 Ok(())
191}
192
193fn check_and_set_agent_approval(
195 spec: &mut Spec,
196 commits: &[String],
197 _config: &Config,
198) -> Result<()> {
199 use crate::spec::{Approval, ApprovalStatus};
200
201 if spec.frontmatter.approval.is_some() {
203 return Ok(());
204 }
205
206 for commit in commits {
208 match detect_agent_in_commit(commit) {
209 Ok(result) if result.has_agent => {
210 spec.frontmatter.approval = Some(Approval {
212 required: true,
213 status: ApprovalStatus::Pending,
214 by: None,
215 at: None,
216 });
217 return Ok(());
218 }
219 Ok(_) => {
220 }
222 Err(e) => {
223 eprintln!(
225 "Warning: Failed to check commit {} for agent: {}",
226 commit, e
227 );
228 }
229 }
230 }
231
232 Ok(())
233}
234
235fn has_agent_log(spec_id: &str) -> bool {
237 use crate::paths::LOGS_DIR;
238 use std::path::PathBuf;
239
240 let logs_dir = PathBuf::from(LOGS_DIR);
241
242 let log_path = logs_dir.join(format!("{}.log", spec_id));
244 if log_path.exists() {
245 return true;
246 }
247
248 if let Ok(entries) = std::fs::read_dir(&logs_dir) {
250 for entry in entries.flatten() {
251 let filename = entry.file_name();
252 let filename_str = filename.to_string_lossy();
253
254 if filename_str.starts_with(&format!("{}.", spec_id)) && filename_str.ends_with(".log")
256 {
257 let middle = &filename_str[spec_id.len() + 1..filename_str.len() - 4];
259 if middle.parse::<u32>().is_ok() {
260 return true;
261 }
262 }
263 }
264 }
265
266 false
267}