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_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}
25
26pub fn finalize_spec(
38 spec: &mut Spec,
39 spec_repo: &FileSpecRepository,
40 config: &Config,
41 all_specs: &[Spec],
42 options: FinalizeOptions,
43) -> Result<()> {
44 use crate::spec;
45
46 if let Some(worktree_path) = worktree::get_active_worktree(&spec.id, None) {
48 if worktree::has_uncommitted_changes(&worktree_path)? {
49 anyhow::bail!(
50 "Cannot finalize: uncommitted changes in worktree. Commit your changes first.\nWorktree: {}",
51 worktree_path.display()
52 );
53 }
54 }
55
56 let incomplete_members = spec::get_incomplete_members(&spec.id, all_specs);
58 if !incomplete_members.is_empty() {
59 anyhow::bail!(
60 "Cannot complete driver spec '{}' while {} member spec(s) are incomplete: {}",
61 spec.id,
62 incomplete_members.len(),
63 incomplete_members.join(", ")
64 );
65 }
66
67 let commits = match options.commits {
69 Some(c) => c,
70 None => {
71 let spec_branch = spec.frontmatter.branch.as_deref();
73 if spec_branch.is_some() && !options.allow_no_commits {
74 get_commits_for_spec_with_branch(&spec.id, spec_branch)?
75 } else if options.allow_no_commits {
76 get_commits_for_spec_allow_no_commits(&spec.id)?
77 } else if let Some(branch) = spec_branch {
78 get_commits_for_spec_with_branch_allow_no_commits(&spec.id, Some(branch))?
79 } else {
80 get_commits_for_spec_allow_no_commits(&spec.id)?
81 }
82 }
83 };
84
85 if config.approval.require_approval_for_agent_work {
87 check_and_set_agent_approval(spec, &commits, config)?;
88 }
89
90 TransitionBuilder::new(spec)
92 .to(SpecStatus::Completed)
93 .context("Failed to transition spec to Completed status")?;
94
95 spec.frontmatter.commits = if commits.is_empty() {
96 None
97 } else {
98 Some(commits)
99 };
100 spec.frontmatter.completed_at = Some(crate::utc_now_iso());
101 spec.frontmatter.model = get_model_name(Some(config));
102
103 spec_repo
105 .save(spec)
106 .context("Failed to save finalized spec")?;
107
108 anyhow::ensure!(
110 spec.frontmatter.status == SpecStatus::Completed,
111 "Status was not set to Completed after finalization"
112 );
113
114 let completed_at = spec
116 .frontmatter
117 .completed_at
118 .as_ref()
119 .ok_or_else(|| anyhow::anyhow!("completed_at timestamp was not set"))?;
120
121 chrono::DateTime::parse_from_rfc3339(completed_at).with_context(|| {
123 format!(
124 "completed_at must be valid ISO 8601 format, got: {}",
125 completed_at
126 )
127 })?;
128
129 let saved_spec = spec_repo
131 .load(&spec.id)
132 .context("Failed to reload spec from disk to verify persistence")?;
133
134 anyhow::ensure!(
135 saved_spec.frontmatter.status == SpecStatus::Completed,
136 "Persisted spec status is not Completed - save may have failed"
137 );
138
139 anyhow::ensure!(
140 saved_spec.frontmatter.completed_at.is_some(),
141 "Persisted spec is missing completed_at - save may have failed"
142 );
143
144 match (&spec.frontmatter.commits, &saved_spec.frontmatter.commits) {
146 (Some(mem_commits), Some(saved_commits)) => {
147 anyhow::ensure!(
148 mem_commits == saved_commits,
149 "Persisted commits don't match memory - save may have failed"
150 );
151 }
152 (None, None) => {
153 }
155 _ => {
156 anyhow::bail!("Persisted commits don't match memory - save may have failed");
157 }
158 }
159
160 Ok(())
161}
162
163fn check_and_set_agent_approval(
165 spec: &mut Spec,
166 commits: &[String],
167 _config: &Config,
168) -> Result<()> {
169 use crate::spec::{Approval, ApprovalStatus};
170
171 if spec.frontmatter.approval.is_some() {
173 return Ok(());
174 }
175
176 for commit in commits {
178 match detect_agent_in_commit(commit) {
179 Ok(result) if result.has_agent => {
180 spec.frontmatter.approval = Some(Approval {
182 required: true,
183 status: ApprovalStatus::Pending,
184 by: None,
185 at: None,
186 });
187 return Ok(());
188 }
189 Ok(_) => {
190 }
192 Err(e) => {
193 eprintln!(
195 "Warning: Failed to check commit {} for agent: {}",
196 commit, e
197 );
198 }
199 }
200 }
201
202 Ok(())
203}