Skip to main content

sloop/
post.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::fs;
4use std::fs::OpenOptions;
5use std::io::{self, Write};
6use std::path::{Component, Path, PathBuf};
7use std::sync::Mutex;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use async_trait::async_trait;
11use rusqlite::{Transaction, TransactionBehavior};
12use serde_json::{Value, json};
13
14use crate::config::{AgentConfig, expand_agent_cmd};
15use crate::db::StoreError;
16use crate::domain::ticket::TicketState;
17use crate::domain::trigger::TriggerKind;
18use crate::domain::work::{ExecutionHints, SourceVersion, TicketRef, WorkTicket, WorkTicketState};
19use crate::flow::Flow;
20use crate::frontmatter::{self, FrontmatterError};
21use crate::ids::{IdError, next_id};
22use crate::protocol::{PostArgs, PostTrigger};
23use crate::work_state::local::{self, LocalSqlite, LocalTicketWrite};
24use crate::work_state::trigger::{self, Duplicates, EnqueueRequest};
25use crate::work_state::{SourceError, WorkStateAuthor};
26
27#[derive(Clone, Copy)]
28struct TriggerRequest {
29    kind: TriggerKind,
30    eligible_at_ms: Option<i64>,
31}
32
33static POST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
34
35struct StagedWrite {
36    path: PathBuf,
37    target: PathBuf,
38    persisted: bool,
39}
40
41impl StagedWrite {
42    fn new(target: PathBuf, content: &str) -> io::Result<Self> {
43        let parent = target.parent().unwrap_or_else(|| Path::new("."));
44        let name = target
45            .file_name()
46            .and_then(|name| name.to_str())
47            .unwrap_or("ticket");
48        let (path, mut file) = loop {
49            let ordinal = POST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
50            let path = parent.join(format!(
51                ".{name}.sloop-post-{}-{ordinal}.tmp",
52                std::process::id()
53            ));
54            match OpenOptions::new().write(true).create_new(true).open(&path) {
55                Ok(file) => break (path, file),
56                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
57                Err(error) => return Err(error),
58            }
59        };
60        let staged = Self {
61            path,
62            target,
63            persisted: false,
64        };
65        if let Ok(metadata) = fs::metadata(&staged.target) {
66            fs::set_permissions(&staged.path, metadata.permissions())?;
67        }
68        file.write_all(content.as_bytes())?;
69        file.sync_all()?;
70        Ok(staged)
71    }
72
73    fn persist(mut self) -> io::Result<()> {
74        fs::rename(&self.path, &self.target)?;
75        self.persisted = true;
76        Ok(())
77    }
78}
79
80impl Drop for StagedWrite {
81    fn drop(&mut self) {
82        if !self.persisted {
83            let _ = fs::remove_file(&self.path);
84        }
85    }
86}
87
88/// Local markdown authoring over SQLite.
89///
90/// A [`SourceVersion`] is the lowercase hexadecimal FNV-1a hash of the
91/// complete markdown file content. Updates compare that version with the file
92/// immediately before committing, so a concurrent edit is rejected instead
93/// of silently overwritten.
94struct MarkdownWorkStateAuthor<'a> {
95    root: &'a Path,
96    file_path: &'a str,
97    worktree: &'a str,
98    work_state: &'a LocalSqlite,
99    original_content: &'a str,
100    final_content: &'a str,
101    original_version: SourceVersion,
102    trigger: Option<TriggerRequest>,
103    now_ms: i64,
104    trigger_result: Mutex<Value>,
105}
106
107impl MarkdownWorkStateAuthor<'_> {
108    fn absolute_path(&self) -> PathBuf {
109        self.root.join(self.file_path)
110    }
111
112    fn ensure_source_version(&self, expected: &SourceVersion) -> Result<(), SourceError> {
113        let path = self.absolute_path();
114        let content = fs::read_to_string(&path).map_err(|error| SourceError::Corrupt {
115            message: format!("cannot read {}: {error}", path.display()),
116        })?;
117        let actual = source_version(&content);
118        if &actual != expected {
119            return Err(SourceError::Rejected {
120                message: format!(
121                    "source version conflict for `{}`: expected {}, found {}",
122                    self.file_path, expected.0, actual.0
123                ),
124            });
125        }
126        Ok(())
127    }
128
129    fn commit(
130        &self,
131        ticket: &WorkTicket,
132        update: bool,
133        expected: &SourceVersion,
134    ) -> Result<(), SourceError> {
135        let staged = (self.final_content != self.original_content)
136            .then(|| StagedWrite::new(self.absolute_path(), self.final_content))
137            .transpose()
138            .map_err(|error| SourceError::Corrupt {
139                message: format!("cannot stage {}: {error}", self.file_path),
140            })?;
141        self.ensure_source_version(expected)?;
142        let db = self.work_state.db();
143        let mut connection = db.lock();
144        let transaction = connection
145            .transaction_with_behavior(TransactionBehavior::Immediate)
146            .map_err(StoreError::from)
147            .map_err(source_store_error)?;
148        let write = LocalTicketWrite {
149            id: &ticket.id,
150            project_id: &ticket.project_id,
151            file_path: self.file_path,
152            name: &ticket.name,
153            blocked_by: &ticket.blocked_by,
154            worktree: self.worktree,
155            target: ticket.hints.target.as_deref(),
156            model: ticket.hints.model.as_deref(),
157            effort: ticket.hints.effort.as_deref(),
158            flow: ticket.hints.flow.as_deref().unwrap_or_default(),
159            state: ticket.state.to_ticket_state(),
160            body: &ticket.body,
161            content_hash: &ticket.version.0,
162            now_ms: self.now_ms,
163        };
164        if update {
165            local::tx::update_authored_ticket(&transaction, &write).map_err(source_store_error)?;
166        } else {
167            local::tx::insert_authored_ticket(&transaction, &write).map_err(source_store_error)?;
168        }
169        let trigger =
170            queue_trigger_transaction(&transaction, &ticket.id, self.trigger, self.now_ms)
171                .map_err(source_store_error)?;
172        self.ensure_source_version(expected)?;
173        transaction
174            .commit()
175            .map_err(StoreError::from)
176            .map_err(source_store_error)?;
177        drop(connection);
178
179        if let Some(staged) = staged {
180            staged.persist().map_err(|error| SourceError::Corrupt {
181                message: format!("cannot replace {}: {error}", self.file_path),
182            })?;
183        }
184        *self
185            .trigger_result
186            .lock()
187            .unwrap_or_else(|poisoned| poisoned.into_inner()) = trigger;
188        Ok(())
189    }
190
191    fn trigger_result(&self) -> Value {
192        self.trigger_result
193            .lock()
194            .unwrap_or_else(|poisoned| poisoned.into_inner())
195            .clone()
196    }
197}
198
199#[async_trait]
200impl WorkStateAuthor for MarkdownWorkStateAuthor<'_> {
201    async fn post(&self, ticket: &WorkTicket) -> Result<TicketRef, SourceError> {
202        self.commit(ticket, false, &self.original_version)?;
203        Ok(TicketRef {
204            id: ticket.id.clone(),
205            source: "local".into(),
206            source_ref: Some(self.file_path.into()),
207        })
208    }
209
210    async fn update(
211        &self,
212        ticket: &TicketRef,
213        content: &WorkTicket,
214        expected: &SourceVersion,
215    ) -> Result<SourceVersion, SourceError> {
216        if ticket.id != content.id || ticket.source_ref.as_deref() != Some(self.file_path) {
217            return Err(SourceError::Rejected {
218                message: format!("ticket reference conflict for `{}`", content.id),
219            });
220        }
221        self.commit(content, true, expected)?;
222        Ok(content.version.clone())
223    }
224}
225
226/// Registers a ticket file: validates and stamps frontmatter, indexes the
227/// ticket, and for `auto` and `at` creates one queued trigger. Reposting
228/// a stamped file is idempotent; reposting with a different `--at` time
229/// reschedules the queued trigger. The dispatcher is the only caller and
230/// computes `at_eligible_ms` from its injected clock, so plain reads before
231/// writes here cannot race another writer.
232///
233/// A trigger is queued only when the post leaves the ticket in `ready`.
234/// Reposting a settled ticket still refreshes the indexed content — editing
235/// a merged ticket's file must keep working — but queues nothing.
236#[allow(clippy::too_many_arguments)]
237pub async fn handle(
238    root: &Path,
239    ticket_dir: &Path,
240    work_state: &LocalSqlite,
241    args: &PostArgs,
242    now_ms: i64,
243    at_eligible_ms: Option<i64>,
244    ticket_prefix: &str,
245    agent: Option<&AgentConfig>,
246    flows: &BTreeMap<String, Flow>,
247    default_flow: &str,
248) -> Result<Value, PostError> {
249    let initial_state = match args.trigger {
250        PostTrigger::Hold => TicketState::Held,
251        _ => TicketState::Ready,
252    };
253    let relative = repository_relative(root, ticket_dir, &args.file)?;
254    let relative_str = relative.to_string_lossy().into_owned();
255    let absolute = root.join(&relative);
256    let content = fs::read_to_string(&absolute).map_err(|source| {
257        if source.kind() == io::ErrorKind::NotFound {
258            PostError::TicketFileNotFound(relative_str.clone())
259        } else {
260            PostError::Io {
261                path: relative_str.clone(),
262                source,
263            }
264        }
265    })?;
266    let stamped = parse_ticket_frontmatter(&content, &relative_str)?;
267
268    let project = match (stamped.project.as_deref(), args.project.as_deref()) {
269        (Some(stamped), Some(requested)) if stamped != requested => {
270            return Err(PostError::ProjectConflict {
271                path: relative_str,
272                stamped: stamped.into(),
273                requested: requested.into(),
274            });
275        }
276        (Some(stamped), _) => stamped.to_owned(),
277        (None, Some(requested)) => requested.to_owned(),
278        (None, None) => "default".to_owned(),
279    };
280    if !work_state.project_exists(&project)? {
281        return Err(PostError::UnknownProject(project));
282    }
283
284    let flow_name = match (stamped.flow.as_deref(), args.flow.as_deref()) {
285        (Some(stamped), Some(requested)) if stamped != requested => {
286            return Err(PostError::FlowConflict {
287                path: relative_str,
288                stamped: stamped.into(),
289                requested: requested.into(),
290            });
291        }
292        (Some(stamped), _) => stamped.to_owned(),
293        (None, Some(requested)) => requested.to_owned(),
294        (None, None) => default_flow.to_owned(),
295    };
296    if !flows.contains_key(&flow_name) {
297        let mut known: Vec<&str> = flows.keys().map(String::as_str).collect();
298        known.sort_unstable();
299        return Err(PostError::UnknownFlow {
300            flow: flow_name,
301            known: known.into_iter().map(str::to_owned).collect(),
302        });
303    }
304
305    let target = match stamped.target.as_deref() {
306        Some(target) if agent.is_some_and(|agent| agent.targets.contains_key(target)) => {
307            Some(target.to_owned())
308        }
309        Some(target) => return Err(PostError::UnknownTarget(target.to_owned())),
310        None => agent.map(|agent| agent.default_target.clone()),
311    };
312    if let (Some(agent), Some(target)) = (agent, target.as_deref()) {
313        let command = agent
314            .targets
315            .get(target)
316            .expect("configured default target was validated");
317        expand_agent_cmd(
318            command,
319            stamped.model.as_deref(),
320            stamped.effort.as_deref(),
321            "",
322        )
323        .map_err(|message| PostError::MissingTargetValue {
324            target: target.to_owned(),
325            message,
326        })?;
327    }
328
329    let (ticket_id, existing) = match stamped.id.as_deref() {
330        Some(id) => {
331            if let Some(existing) = work_state.ticket(id)? {
332                if existing.file_path.as_deref() != Some(relative_str.as_str()) {
333                    return Err(PostError::TicketIdTaken {
334                        id: id.to_owned(),
335                        file: existing.file_path.unwrap_or_default(),
336                    });
337                }
338                if existing.project_id != project {
339                    return Err(PostError::ProjectConflict {
340                        path: relative_str,
341                        stamped: project,
342                        requested: existing.project_id,
343                    });
344                }
345                (id.to_owned(), Some(existing))
346            } else {
347                (id.to_owned(), None)
348            }
349        }
350        None => match work_state.ticket_by_file(&relative_str)? {
351            Some(existing) => {
352                if existing.project_id != project {
353                    return Err(PostError::ProjectConflict {
354                        path: relative_str,
355                        stamped: project,
356                        requested: existing.project_id,
357                    });
358                }
359                (existing.id.clone(), Some(existing))
360            }
361            None => (allocate_ticket_id(work_state, ticket_prefix)?, None),
362        },
363    };
364    for blocker in &stamped.blocked_by {
365        if blocker != &ticket_id && work_state.ticket(blocker)?.is_none() {
366            return Err(PostError::UnknownBlockedBy {
367                ticket: ticket_id.clone(),
368                blocker: blocker.clone(),
369                named: work_state.ticket_by_name(blocker)?.map(|ticket| ticket.id),
370            });
371        }
372    }
373    let mut dependencies = work_state.ticket_dependencies()?;
374    dependencies.insert(ticket_id.clone(), stamped.blocked_by.clone());
375    if let Some(chain) = crate::domain::graph::find_cycle(&dependencies) {
376        return Err(PostError::DependencyCycle(chain));
377    }
378
379    let worktree = match stamped.worktree.clone() {
380        Some(worktree) => worktree,
381        None => {
382            let stem = Path::new(&relative_str)
383                .file_stem()
384                .and_then(|stem| stem.to_str());
385            crate::ids::default_worktree(stem, &ticket_id).map_err(|reason| {
386                PostError::InvalidWorktreeStem {
387                    path: relative_str.clone(),
388                    reason,
389                }
390            })?
391        }
392    };
393    let stamp = frontmatter::stamp(&content, &ticket_id, &project, &worktree, &flow_name).map_err(
394        |error| PostError::InvalidTicket {
395            path: relative_str.clone(),
396            error,
397        },
398    )?;
399    let file_rewritten = stamp.is_some();
400    let final_content = stamp.unwrap_or_else(|| content.clone());
401    let terminal_state = existing
402        .as_ref()
403        .map(|ticket| ticket.state.clone())
404        .filter(|state| matches!(state.as_str(), "merged" | "failed" | "needs_review"));
405    let trigger_request = if terminal_state.is_some() {
406        None
407    } else {
408        match &args.trigger {
409            PostTrigger::Manual | PostTrigger::Hold => None,
410            PostTrigger::Auto => Some(TriggerRequest {
411                kind: TriggerKind::Auto,
412                eligible_at_ms: None,
413            }),
414            PostTrigger::At { .. } => Some(TriggerRequest {
415                kind: TriggerKind::At,
416                eligible_at_ms: Some(
417                    at_eligible_ms.expect("the dispatcher computes eligibility for at triggers"),
418                ),
419            }),
420        }
421    };
422    let work_ticket = WorkTicket {
423        id: ticket_id.clone(),
424        project_id: project.clone(),
425        name: stamped.name.clone(),
426        body: frontmatter::body(&content)
427            .expect("validated frontmatter has a body")
428            .to_owned(),
429        state: WorkTicketState::from_ticket_state(
430            initial_state,
431            false,
432            String::new(),
433            crate::domain::work::OwnerId(String::new()),
434        ),
435        blocked_by: stamped.blocked_by.clone(),
436        attempts: existing.as_ref().map_or(0, |ticket| ticket.attempts as u32),
437        hints: ExecutionHints {
438            worktree: Some(worktree.clone()),
439            trigger_id: None,
440            target,
441            model: stamped.model.clone(),
442            effort: stamped.effort.clone(),
443            flow: Some(flow_name.clone()),
444        },
445        version: source_version(&final_content),
446    };
447    let author = MarkdownWorkStateAuthor {
448        root,
449        file_path: &relative_str,
450        worktree: &worktree,
451        work_state,
452        original_content: &content,
453        final_content: &final_content,
454        original_version: source_version(&content),
455        trigger: trigger_request,
456        now_ms,
457        trigger_result: Mutex::new(Value::Null),
458    };
459    let ticket_ref = TicketRef {
460        id: ticket_id,
461        source: "local".into(),
462        source_ref: Some(relative_str.clone()),
463    };
464    let created = existing.is_none();
465    if created {
466        author.post(&work_ticket).await?;
467    } else {
468        author
469            .update(&ticket_ref, &work_ticket, &author.original_version)
470            .await?;
471    }
472    let trigger = author.trigger_result();
473    let ticket = work_state
474        .ticket(&work_ticket.id)?
475        .expect("registered ticket still exists");
476
477    Ok(json!({
478        "ticket": {
479            "id": ticket.id,
480            "project": project,
481            "file": relative_str,
482            "state": ticket.state,
483            "name": ticket.name,
484            "blocked_by": ticket.blocked_by,
485            "worktree": ticket.worktree,
486            "target": ticket.target,
487            "model": ticket.model,
488            "effort": ticket.effort,
489            "flow": ticket.flow,
490        },
491        "created": created,
492        "file_rewritten": file_rewritten,
493        "trigger": trigger,
494        "trigger_suppressed": terminal_state.map(|state| json!({
495            "reason": "terminal_ticket",
496            "state": state,
497        })),
498    }))
499}
500
501/// Validates a ticket file, reporting *every* independent problem at once so
502/// authoring a ticket does not turn into one round-trip per mistake.
503///
504/// The split between short-circuiting and accumulating is deliberate. A file
505/// whose frontmatter cannot be read at all — no block, unterminated, YAML
506/// that does not parse, a block that is not a mapping — fails fast: no field
507/// can be read out of it, so every other check would either be unanswerable
508/// or degenerate into "everything is missing". Once a mapping is in hand,
509/// each field and the body are independent, and the caller deserves the full
510/// list. Checks that need the store (unknown blockers, dependency cycles,
511/// project/flow/target resolution) stay in `handle`: they are registration
512/// problems rather than problems with the file, and they carry their own
513/// error codes.
514pub(crate) fn parse_ticket_frontmatter(
515    content: &str,
516    path: &str,
517) -> Result<frontmatter::Frontmatter, PostError> {
518    let (stamped, field_errors) =
519        frontmatter::parse_collecting(content).map_err(|error| PostError::InvalidTicket {
520            path: path.to_owned(),
521            error,
522        })?;
523
524    let name_is_reported = field_errors
525        .iter()
526        .any(|error| matches!(error, FrontmatterError::InvalidFieldType { key } if key == "name"));
527    let blocked_by_is_reported = field_errors
528        .iter()
529        .any(|error| matches!(error, FrontmatterError::InvalidBlockedBy));
530
531    let mut problems = Vec::new();
532    if !name_is_reported && stamped.name.trim().is_empty() {
533        problems.push(TicketProblem::MissingName);
534    }
535    if !blocked_by_is_reported && !stamped.has_blocked_by() {
536        problems.push(TicketProblem::MissingBlockedBy);
537    }
538    if frontmatter::body(content)
539        .expect("frontmatter was already parsed")
540        .trim()
541        .is_empty()
542    {
543        problems.push(TicketProblem::EmptyBody);
544    }
545    problems.extend(field_errors.into_iter().map(TicketProblem::from));
546
547    if problems.is_empty() {
548        Ok(stamped)
549    } else {
550        Err(PostError::InvalidTicketFields {
551            path: path.to_owned(),
552            problems,
553        })
554    }
555}
556
557/// Queues the demand a post asked for, inside the transaction that registers
558/// the ticket. `Duplicates::Reuse` is what makes reposting idempotent: an
559/// existing queued trigger of the same kind absorbs the request instead of
560/// piling a second one behind it.
561///
562/// `request` is `None` for a settled ticket, which is what keeps the reuse
563/// branch from re-timing a stale `--at` trigger onto one: the caller decides
564/// eligibility, this function only carries it out.
565fn queue_trigger_transaction(
566    transaction: &Transaction<'_>,
567    ticket_id: &str,
568    request: Option<TriggerRequest>,
569    now_ms: i64,
570) -> Result<Value, StoreError> {
571    let Some(request) = request else {
572        return Ok(Value::Null);
573    };
574    let id = trigger::enqueue(
575        transaction,
576        &EnqueueRequest {
577            kind: request.kind,
578            ticket_id: Some(ticket_id),
579            project_id: None,
580            eligible_at_ms: request.eligible_at_ms,
581            interval_ms: None,
582            filters: &[],
583            duplicates: Duplicates::Reuse,
584        },
585        now_ms,
586    )?
587    .id;
588    let mut trigger = json!({
589        "id": id,
590        "kind": request.kind.as_str(),
591        "state": "queued",
592        "ticket": ticket_id,
593    });
594    if let Some(eligible_at_ms) = request.eligible_at_ms {
595        trigger["eligible_at_ms"] = json!(eligible_at_ms);
596    }
597    Ok(trigger)
598}
599
600fn source_store_error(error: StoreError) -> SourceError {
601    if error.is_disk_full() {
602        SourceError::Unavailable { retry_after: None }
603    } else {
604        SourceError::Corrupt {
605            message: error.to_string(),
606        }
607    }
608}
609
610fn source_version(content: &str) -> SourceVersion {
611    let mut hash = 0xcbf29ce484222325_u64;
612    for byte in content.as_bytes() {
613        hash ^= u64::from(*byte);
614        hash = hash.wrapping_mul(0x100000001b3);
615    }
616    SourceVersion(format!("{hash:016x}"))
617}
618
619fn allocate_ticket_id(work_state: &LocalSqlite, prefix: &str) -> Result<String, PostError> {
620    let ids = work_state.ticket_ids()?;
621    next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
622}
623
624/// Resolves the request path against the repository root and requires the
625/// result to stay inside the committed Sloop ticket directory.
626fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
627    let path = Path::new(file);
628    let joined = if path.is_absolute() {
629        path.to_path_buf()
630    } else {
631        root.join(path)
632    };
633
634    let mut normalized = PathBuf::new();
635    for component in joined.components() {
636        match component {
637            Component::CurDir => {}
638            Component::ParentDir => {
639                if !normalized.pop() {
640                    return Err(PostError::OutsideRepository(file.to_owned()));
641                }
642            }
643            component => normalized.push(component),
644        }
645    }
646    let relative = resolve_symlinks(&normalized)
647        .strip_prefix(resolve_symlinks(root))
648        .map(Path::to_path_buf)
649        .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
650    if !relative.starts_with(ticket_dir) {
651        return Err(PostError::OutsideTicketDirectory {
652            path: file.to_owned(),
653            directory: ticket_dir.to_path_buf(),
654        });
655    }
656    Ok(relative)
657}
658
659/// Resolves `path` through any symlinks along it, so containment is decided on
660/// the same footing as the repository root, which `Repository::discover`
661/// always canonicalizes. Without this, a root reached through a symlink — the
662/// normal case on macOS, where `/tmp` and `/var/folders` are both links into
663/// `/private` — makes every absolute path the operator types look external.
664///
665/// Containment is checked before the ticket file is read, so the path need not
666/// exist yet. The longest existing ancestor is canonicalized and the remaining
667/// components are appended unresolved, which keeps a missing file inside the
668/// ticket directory reported as `not found` rather than as an escape. A path
669/// with no resolvable ancestor keeps its lexical form for the same reason.
670fn resolve_symlinks(path: &Path) -> PathBuf {
671    let mut unresolved = Vec::new();
672    let mut prefix = path;
673    loop {
674        if let Ok(resolved) = prefix.canonicalize() {
675            return unresolved
676                .iter()
677                .rev()
678                .fold(resolved, |base, component| base.join(component));
679        }
680        match (prefix.parent(), prefix.file_name()) {
681            (Some(parent), Some(name)) => {
682                unresolved.push(name);
683                prefix = parent;
684            }
685            _ => return path.to_path_buf(),
686        }
687    }
688}
689
690/// A single problem with a ticket file, phrased without the file path so
691/// several can be listed under one path heading.
692#[derive(Debug)]
693pub enum TicketProblem {
694    Frontmatter(FrontmatterError),
695    MissingName,
696    MissingBlockedBy,
697    InvalidBlockedBy,
698    EmptyBody,
699}
700
701impl From<FrontmatterError> for TicketProblem {
702    fn from(error: FrontmatterError) -> Self {
703        match error {
704            FrontmatterError::InvalidBlockedBy => Self::InvalidBlockedBy,
705            error => Self::Frontmatter(error),
706        }
707    }
708}
709
710impl fmt::Display for TicketProblem {
711    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
712        match self {
713            Self::Frontmatter(error) => error.fmt(formatter),
714            Self::MissingName => {
715                formatter.write_str("missing or empty `name`; add `name: Your ticket title`")
716            }
717            Self::MissingBlockedBy => formatter.write_str(
718                "missing `blocked_by`; add `blocked_by: []` if there are no dependencies",
719            ),
720            Self::InvalidBlockedBy => formatter.write_str(
721                "invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs",
722            ),
723            Self::EmptyBody => {
724                formatter.write_str("empty `body`; add a ticket description after the frontmatter")
725            }
726        }
727    }
728}
729
730#[derive(Debug)]
731pub enum PostError {
732    TicketFileNotFound(String),
733    OutsideRepository(String),
734    OutsideTicketDirectory {
735        path: String,
736        directory: PathBuf,
737    },
738    InvalidTicket {
739        path: String,
740        error: FrontmatterError,
741    },
742    /// One or more independent problems with the ticket file itself,
743    /// reported together. Never empty.
744    InvalidTicketFields {
745        path: String,
746        problems: Vec<TicketProblem>,
747    },
748    InvalidWorktreeStem {
749        path: String,
750        reason: String,
751    },
752    UnknownBlockedBy {
753        ticket: String,
754        blocker: String,
755        /// The id of a ticket whose *name* matches the unknown blocker, when
756        /// one exists: the likeliest authoring mistake is naming a ticket
757        /// where its stamped id belongs, and the error can say so.
758        named: Option<String>,
759    },
760    DependencyCycle(Vec<String>),
761    UnknownProject(String),
762    UnknownTarget(String),
763    MissingTargetValue {
764        target: String,
765        message: String,
766    },
767    ProjectConflict {
768        path: String,
769        stamped: String,
770        requested: String,
771    },
772    FlowConflict {
773        path: String,
774        stamped: String,
775        requested: String,
776    },
777    UnknownFlow {
778        flow: String,
779        known: Vec<String>,
780    },
781    TicketIdTaken {
782        id: String,
783        file: String,
784    },
785    Io {
786        path: String,
787        source: io::Error,
788    },
789    Source(SourceError),
790    Store(StoreError),
791    IdAllocation(IdError),
792}
793
794impl From<SourceError> for PostError {
795    fn from(error: SourceError) -> Self {
796        Self::Source(error)
797    }
798}
799
800impl From<StoreError> for PostError {
801    fn from(error: StoreError) -> Self {
802        Self::Store(error)
803    }
804}
805
806impl fmt::Display for PostError {
807    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
808        match self {
809            Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
810            Self::OutsideRepository(path) => {
811                write!(formatter, "`{path}` is outside the repository")
812            }
813            Self::OutsideTicketDirectory { path, directory } => write!(
814                formatter,
815                "`{path}` is outside the {} directory",
816                directory.display()
817            ),
818            Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
819            Self::InvalidTicketFields { path, problems } => match problems.as_slice() {
820                [problem] => write!(formatter, "{path}: {problem}"),
821                problems => {
822                    write!(formatter, "{path}:")?;
823                    for problem in problems {
824                        write!(formatter, "\n  - {problem}")?;
825                    }
826                    Ok(())
827                }
828            },
829            Self::InvalidWorktreeStem { path, reason } => {
830                write!(formatter, "{path}: {reason}")
831            }
832            Self::UnknownBlockedBy {
833                ticket,
834                blocker,
835                named,
836            } => {
837                write!(
838                    formatter,
839                    "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
840                )?;
841                if let Some(id) = named {
842                    write!(
843                        formatter,
844                        "; a ticket named `{blocker}` has id `{id}`, and `blocked_by` takes ids"
845                    )?;
846                }
847                Ok(())
848            }
849            Self::DependencyCycle(chain) => write!(
850                formatter,
851                "field `blocked_by` creates a dependency cycle: {}",
852                chain.join(" -> ")
853            ),
854            Self::UnknownProject(project) => {
855                write!(formatter, "project `{project}` is not indexed")
856            }
857            Self::UnknownTarget(target) => {
858                write!(formatter, "agent target `{target}` is not configured")
859            }
860            Self::MissingTargetValue { target, message } => {
861                write!(formatter, "ticket using agent target `{target}` {message}")
862            }
863            Self::ProjectConflict {
864                path,
865                stamped,
866                requested,
867            } => write!(
868                formatter,
869                "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
870            ),
871            Self::FlowConflict {
872                path,
873                stamped,
874                requested,
875            } => write!(
876                formatter,
877                "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
878            ),
879            Self::UnknownFlow { flow, known } => write!(
880                formatter,
881                "flow `{flow}` is not defined; known flows: {}",
882                known.join(", ")
883            ),
884            Self::TicketIdTaken { id, file } => write!(
885                formatter,
886                "ticket ID `{id}` is already registered by `{file}`"
887            ),
888            Self::Io { path, source } => write!(formatter, "{path}: {source}"),
889            Self::Source(error) => error.fmt(formatter),
890            Self::Store(error) => error.fmt(formatter),
891            Self::IdAllocation(error) => error.fmt(formatter),
892        }
893    }
894}
895
896impl std::error::Error for PostError {}
897
898#[cfg(test)]
899mod tests {
900    use std::collections::BTreeMap;
901
902    use tempfile::tempdir;
903
904    use super::{
905        MarkdownWorkStateAuthor, PostError, handle as handle_with_directory, source_version,
906    };
907    use crate::config::{AgentConfig, AgentTarget};
908    use crate::db::Db;
909    use crate::domain::work::{ExecutionHints, TicketRef, WorkTicket, WorkTicketState};
910    use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage};
911    use crate::protocol::{PostArgs, PostTrigger};
912    use crate::work_state::local::LocalSqlite;
913    use crate::work_state::{SourceError, WorkStateAuthor};
914
915    fn world() -> (tempfile::TempDir, LocalSqlite) {
916        let root = tempdir().unwrap();
917        std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
918        let store = LocalSqlite::from_db(Db::open(&root.path().join("sloop.db"), 1_000).unwrap());
919        store
920            .upsert_local_project(
921                "default",
922                ".agents/sloop/projects/default.md",
923                "Default",
924                1_000,
925            )
926            .unwrap();
927        (root, store)
928    }
929
930    #[allow(clippy::too_many_arguments)]
931    fn handle(
932        root: &std::path::Path,
933        store: &LocalSqlite,
934        args: &PostArgs,
935        now_ms: i64,
936        ticket_prefix: &str,
937        agent: Option<&AgentConfig>,
938        flows: &BTreeMap<String, Flow>,
939        default_flow: &str,
940    ) -> Result<serde_json::Value, PostError> {
941        tokio::runtime::Runtime::new()
942            .unwrap()
943            .block_on(handle_with_directory(
944                root,
945                std::path::Path::new(".agents/sloop/tickets"),
946                store,
947                args,
948                now_ms,
949                None,
950                ticket_prefix,
951                agent,
952                flows,
953                default_flow,
954            ))
955    }
956
957    fn handle_at(
958        root: &std::path::Path,
959        store: &LocalSqlite,
960        args: &PostArgs,
961        now_ms: i64,
962        at_eligible_ms: i64,
963    ) -> Result<serde_json::Value, PostError> {
964        tokio::runtime::Runtime::new()
965            .unwrap()
966            .block_on(handle_with_directory(
967                root,
968                std::path::Path::new(".agents/sloop/tickets"),
969                store,
970                args,
971                now_ms,
972                Some(at_eligible_ms),
973                "TICK",
974                None,
975                &flows(),
976                "default",
977            ))
978    }
979
980    fn post(file: &str, trigger: PostTrigger) -> PostArgs {
981        PostArgs {
982            file: file.into(),
983            project: None,
984            flow: None,
985            trigger,
986        }
987    }
988
989    fn flows() -> BTreeMap<String, Flow> {
990        BTreeMap::from([
991            (
992                "default".to_owned(),
993                Flow {
994                    name: "default".into(),
995                    stages: vec![Stage {
996                        name: "build".into(),
997                        action: Actor::Agent,
998                        result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
999                        fail_action: FailAction::Halt,
1000                        ff_only: false,
1001                    }],
1002                },
1003            ),
1004            (
1005                "release".to_owned(),
1006                Flow {
1007                    name: "release".into(),
1008                    stages: vec![Stage {
1009                        name: "build".into(),
1010                        action: Actor::Agent,
1011                        result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
1012                        fail_action: FailAction::Halt,
1013                        ff_only: false,
1014                    }],
1015                },
1016            ),
1017        ])
1018    }
1019
1020    fn ticket(frontmatter: &str, body: &str) -> String {
1021        format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
1022    }
1023
1024    fn agent() -> AgentConfig {
1025        AgentConfig {
1026            default_target: "claude".into(),
1027            targets: BTreeMap::from([
1028                (
1029                    "claude".into(),
1030                    AgentTarget {
1031                        cmd: vec!["claude".into(), "{prompt}".into()],
1032                        model: None,
1033                        effort: None,
1034                    },
1035                ),
1036                (
1037                    "codex".into(),
1038                    AgentTarget {
1039                        cmd: vec![
1040                            "codex".into(),
1041                            "{model}".into(),
1042                            "{effort}".into(),
1043                            "{prompt}".into(),
1044                        ],
1045                        model: None,
1046                        effort: None,
1047                    },
1048                ),
1049            ]),
1050        }
1051    }
1052
1053    /// Drops a ticket into a state a post cannot leave, the way a settled run
1054    /// would. Going through the run machinery here would say nothing extra
1055    /// about `post`.
1056    fn settle(store: &LocalSqlite, id: &str, state: &str) {
1057        let changed = store
1058            .db()
1059            .lock()
1060            .execute(
1061                "UPDATE tickets SET state = ?2 WHERE id = ?1",
1062                rusqlite::params![id, state],
1063            )
1064            .unwrap();
1065        assert_eq!(changed, 1);
1066    }
1067
1068    #[test]
1069    fn reposting_a_settled_ticket_refreshes_content_without_queuing_an_trigger() {
1070        for state in ["merged", "failed", "needs_review"] {
1071            let (root, store) = world();
1072            let relative = ".agents/sloop/tickets/settled.md";
1073            let path = root.path().join(relative);
1074            std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1075            handle(
1076                root.path(),
1077                &store,
1078                &post(relative, PostTrigger::Manual),
1079                2_000,
1080                "TICK",
1081                None,
1082                &flows(),
1083                "default",
1084            )
1085            .unwrap();
1086            settle(&store, "TICK-1", state);
1087            let stamped = std::fs::read_to_string(&path).unwrap();
1088            std::fs::write(&path, stamped.replace("# Original", "# Edited")).unwrap();
1089
1090            let response = handle(
1091                root.path(),
1092                &store,
1093                &post(relative, PostTrigger::Auto),
1094                3_000,
1095                "TICK",
1096                None,
1097                &flows(),
1098                "default",
1099            )
1100            .unwrap();
1101
1102            assert_eq!(response["ticket"]["id"], "TICK-1");
1103            assert_eq!(response["created"], false);
1104            assert_eq!(
1105                response["ticket"]["state"], state,
1106                "a repost must not resurrect a {state} ticket"
1107            );
1108            assert!(
1109                store
1110                    .ticket("TICK-1")
1111                    .unwrap()
1112                    .unwrap()
1113                    .body
1114                    .unwrap()
1115                    .contains("# Edited")
1116            );
1117            assert!(response["trigger"].is_null());
1118            assert_eq!(
1119                response["trigger_suppressed"],
1120                serde_json::json!({"reason": "terminal_ticket", "state": state})
1121            );
1122            assert!(store.queued_triggers().unwrap().is_empty());
1123        }
1124    }
1125
1126    #[test]
1127    fn reposting_a_settled_ticket_with_at_neither_creates_nor_reschedules_an_trigger() {
1128        let (root, store) = world();
1129        let relative = ".agents/sloop/tickets/timed.md";
1130        std::fs::write(root.path().join(relative), ticket("", "# Timed\n")).unwrap();
1131        let args = post(
1132            relative,
1133            PostTrigger::At {
1134                time: "03:00".into(),
1135            },
1136        );
1137        let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1138        assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1139        settle(&store, "TICK-1", "merged");
1140
1141        let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1142
1143        assert!(second["trigger"].is_null());
1144        assert_eq!(second["trigger_suppressed"]["state"], "merged");
1145        let queued = store.queued_triggers().unwrap();
1146        assert_eq!(queued.len(), 1);
1147        assert_eq!(queued[0].eligible_at_ms, Some(10_000));
1148    }
1149
1150    #[test]
1151    fn posting_twice_reuses_the_registration_and_trigger() {
1152        let (root, store) = world();
1153        std::fs::write(
1154            root.path().join(".agents/sloop/tickets/cooldown.md"),
1155            ticket("", "# Cooldowns\n"),
1156        )
1157        .unwrap();
1158        let args = post(".agents/sloop/tickets/cooldown.md", PostTrigger::Auto);
1159
1160        let first = handle(
1161            root.path(),
1162            &store,
1163            &args,
1164            2_000,
1165            "TICK",
1166            None,
1167            &flows(),
1168            "default",
1169        )
1170        .unwrap();
1171        let second = handle(
1172            root.path(),
1173            &store,
1174            &args,
1175            3_000,
1176            "TICK",
1177            None,
1178            &flows(),
1179            "default",
1180        )
1181        .unwrap();
1182        assert_eq!(first["ticket"]["id"], second["ticket"]["id"]);
1183        assert_eq!(first["trigger"]["id"], second["trigger"]["id"]);
1184        let db = store.db();
1185        let connection = db.lock();
1186        let tickets: i64 = connection
1187            .query_row("SELECT COUNT(*) FROM tickets", [], |row| row.get(0))
1188            .unwrap();
1189        let triggers: i64 = connection
1190            .query_row("SELECT COUNT(*) FROM triggers", [], |row| row.get(0))
1191            .unwrap();
1192        assert_eq!(tickets, 1);
1193        assert_eq!(triggers, 1);
1194    }
1195
1196    #[test]
1197    fn stale_source_version_rejects_update_without_clobbering_the_file() {
1198        let (root, store) = world();
1199        let relative = ".agents/sloop/tickets/cas.md";
1200        let path = root.path().join(relative);
1201        std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1202        handle(
1203            root.path(),
1204            &store,
1205            &post(relative, PostTrigger::Manual),
1206            2_000,
1207            "TICK",
1208            None,
1209            &flows(),
1210            "default",
1211        )
1212        .unwrap();
1213        let original = std::fs::read_to_string(&path).unwrap();
1214        let replacement = original.replace("name: Test ticket", "name: Replacement");
1215        let expected = source_version(&original);
1216        let external_edit = original.replace("# Original", "# External edit");
1217        std::fs::write(&path, &external_edit).unwrap();
1218        let author = MarkdownWorkStateAuthor {
1219            root: root.path(),
1220            file_path: relative,
1221            worktree: "cas",
1222            work_state: &store,
1223            original_content: &original,
1224            final_content: &replacement,
1225            original_version: expected.clone(),
1226            trigger: None,
1227            now_ms: 3_000,
1228            trigger_result: std::sync::Mutex::new(serde_json::Value::Null),
1229        };
1230        let content = WorkTicket {
1231            id: "TICK-1".into(),
1232            project_id: "default".into(),
1233            name: "Replacement".into(),
1234            body: "# Replacement\n".into(),
1235            state: WorkTicketState::Ready,
1236            blocked_by: Vec::new(),
1237            attempts: 0,
1238            hints: ExecutionHints {
1239                worktree: Some("sloop/TICK-1".into()),
1240                trigger_id: None,
1241                target: None,
1242                model: None,
1243                effort: None,
1244                flow: Some("default".into()),
1245            },
1246            version: source_version(&replacement),
1247        };
1248        let ticket_ref = TicketRef {
1249            id: content.id.clone(),
1250            source: "local".into(),
1251            source_ref: Some(relative.into()),
1252        };
1253
1254        let error = tokio::runtime::Runtime::new()
1255            .unwrap()
1256            .block_on(author.update(&ticket_ref, &content, &expected))
1257            .unwrap_err();
1258
1259        assert!(matches!(
1260            error,
1261            SourceError::Rejected { message } if message.contains("source version conflict")
1262        ));
1263        assert_eq!(std::fs::read_to_string(path).unwrap(), external_edit);
1264        assert_eq!(store.ticket("TICK-1").unwrap().unwrap().name, "Test ticket");
1265    }
1266
1267    #[test]
1268    fn trigger_insert_failure_leaves_idless_file_and_database_unchanged() {
1269        let (root, store) = world();
1270        let relative = ".agents/sloop/tickets/fail.md";
1271        let path = root.path().join(relative);
1272        let original = ticket("", "# Failure\n");
1273        std::fs::write(&path, &original).unwrap();
1274        store
1275            .db()
1276            .lock()
1277            .execute_batch(
1278                "CREATE TRIGGER reject_trigger BEFORE INSERT ON triggers
1279                 BEGIN SELECT RAISE(ABORT, 'forced trigger failure'); END;",
1280            )
1281            .unwrap();
1282
1283        let error = handle(
1284            root.path(),
1285            &store,
1286            &post(relative, PostTrigger::Auto),
1287            2_000,
1288            "TICK",
1289            None,
1290            &flows(),
1291            "default",
1292        )
1293        .unwrap_err();
1294
1295        assert!(error.to_string().contains("forced trigger failure"));
1296        assert_eq!(std::fs::read_to_string(path).unwrap(), original);
1297        assert!(store.ticket_ids().unwrap().is_empty());
1298        assert!(store.queued_triggers().unwrap().is_empty());
1299        let next_ordinal: i64 = store
1300            .db()
1301            .lock()
1302            .query_row(
1303                "SELECT next_ordinal FROM id_counters WHERE kind = 'trigger'",
1304                [],
1305                |row| row.get(0),
1306            )
1307            .unwrap();
1308        assert_eq!(next_ordinal, 1);
1309    }
1310
1311    #[test]
1312    fn posting_at_queues_a_timed_trigger_and_reposting_reschedules_it() {
1313        let (root, store) = world();
1314        std::fs::write(
1315            root.path().join(".agents/sloop/tickets/timed.md"),
1316            ticket("", "# Timed\n"),
1317        )
1318        .unwrap();
1319        let args = post(
1320            ".agents/sloop/tickets/timed.md",
1321            PostTrigger::At {
1322                time: "03:00".into(),
1323            },
1324        );
1325
1326        let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1327        assert_eq!(first["ticket"]["state"], "ready");
1328        assert_eq!(first["trigger"]["kind"], "at");
1329        assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1330
1331        let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1332        assert_eq!(second["trigger"]["id"], first["trigger"]["id"]);
1333        assert_eq!(second["trigger"]["eligible_at_ms"], 20_000);
1334
1335        let queued = store.queued_triggers().unwrap();
1336        assert_eq!(queued.len(), 1);
1337        assert_eq!(queued[0].eligible_at_ms, Some(20_000));
1338    }
1339
1340    #[test]
1341    fn posting_snapshots_the_default_target_and_reposting_refreshes_execution_values() {
1342        let (root, store) = world();
1343        let path = root.path().join(".agents/sloop/tickets/work.md");
1344        std::fs::write(&path, ticket("model: sonnet\neffort: medium\n", "# Work\n")).unwrap();
1345        let args = post(".agents/sloop/tickets/work.md", PostTrigger::Manual);
1346        let agent = agent();
1347
1348        let first = handle(
1349            root.path(),
1350            &store,
1351            &args,
1352            2_000,
1353            "TICK",
1354            Some(&agent),
1355            &flows(),
1356            "default",
1357        )
1358        .unwrap();
1359        assert_eq!(first["ticket"]["target"], "claude");
1360
1361        std::fs::write(
1362            &path,
1363            ticket(
1364                "id: TICK-1\nproject: default\ntarget: codex\nmodel: o3\neffort: high\n",
1365                "# Work\n",
1366            ),
1367        )
1368        .unwrap();
1369        let second = handle(
1370            root.path(),
1371            &store,
1372            &args,
1373            3_000,
1374            "TICK",
1375            Some(&agent),
1376            &flows(),
1377            "default",
1378        )
1379        .unwrap();
1380        assert_eq!(second["ticket"]["id"], first["ticket"]["id"]);
1381        assert_eq!(second["ticket"]["target"], "codex");
1382        assert_eq!(second["ticket"]["model"], "o3");
1383        assert_eq!(second["ticket"]["effort"], "high");
1384    }
1385
1386    #[test]
1387    fn unknown_targets_are_rejected_before_registration_or_trigger() {
1388        let (root, store) = world();
1389        std::fs::write(
1390            root.path().join(".agents/sloop/tickets/work.md"),
1391            ticket("target: missing\n", "# Work\n"),
1392        )
1393        .unwrap();
1394        let args = post(".agents/sloop/tickets/work.md", PostTrigger::Auto);
1395
1396        assert!(matches!(
1397            handle(root.path(), &store, &args, 2_000, "TICK", Some(&agent()), &flows(), "default"),
1398            Err(PostError::UnknownTarget(target)) if target == "missing"
1399        ));
1400        assert!(store.ticket_ids().unwrap().is_empty());
1401        assert!(store.queued_triggers().unwrap().is_empty());
1402    }
1403
1404    #[test]
1405    fn selected_target_placeholders_require_ticket_values_before_registration() {
1406        let (root, store) = world();
1407        std::fs::write(
1408            root.path().join(".agents/sloop/tickets/work.md"),
1409            ticket("target: codex\neffort: high\n", "# Work\n"),
1410        )
1411        .unwrap();
1412        let args = post(".agents/sloop/tickets/work.md", PostTrigger::Manual);
1413
1414        let error = handle(
1415            root.path(),
1416            &store,
1417            &args,
1418            2_000,
1419            "TICK",
1420            Some(&agent()),
1421            &flows(),
1422            "default",
1423        )
1424        .unwrap_err()
1425        .to_string();
1426        assert!(error.contains("agent target `codex`"), "{error}");
1427        assert!(error.contains("does not specify `model`"), "{error}");
1428        assert!(store.ticket_ids().unwrap().is_empty());
1429    }
1430
1431    #[test]
1432    fn a_stamped_project_mismatching_the_request_is_a_conflict() {
1433        let (root, store) = world();
1434        std::fs::write(
1435            root.path().join(".agents/sloop/tickets/t.md"),
1436            ticket("id: T1\nproject: default\n", "# Work\n"),
1437        )
1438        .unwrap();
1439        let args = PostArgs {
1440            file: ".agents/sloop/tickets/t.md".into(),
1441            project: Some("other".into()),
1442            flow: None,
1443            trigger: PostTrigger::Manual,
1444        };
1445
1446        assert!(matches!(
1447            handle(
1448                root.path(),
1449                &store,
1450                &args,
1451                2_000,
1452                "TICK",
1453                None,
1454                &flows(),
1455                "default"
1456            ),
1457            Err(PostError::ProjectConflict { .. })
1458        ));
1459    }
1460
1461    #[test]
1462    fn an_unknown_project_is_rejected() {
1463        let (root, store) = world();
1464        std::fs::write(
1465            root.path().join(".agents/sloop/tickets/t.md"),
1466            ticket("", "# T\n"),
1467        )
1468        .unwrap();
1469        let args = PostArgs {
1470            file: ".agents/sloop/tickets/t.md".into(),
1471            project: Some("missing".into()),
1472            flow: None,
1473            trigger: PostTrigger::Manual,
1474        };
1475
1476        assert!(matches!(
1477            handle(root.path(), &store, &args, 2_000, "TICK", None, &flows(), "default"),
1478            Err(PostError::UnknownProject(project)) if project == "missing"
1479        ));
1480    }
1481
1482    #[test]
1483    fn a_missing_flow_is_stamped_with_the_default() {
1484        let (root, store) = world();
1485        let path = root.path().join(".agents/sloop/tickets/t.md");
1486        std::fs::write(&path, ticket("", "# T\n")).unwrap();
1487        let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1488
1489        let response = handle(
1490            root.path(),
1491            &store,
1492            &args,
1493            2_000,
1494            "TICK",
1495            None,
1496            &flows(),
1497            "default",
1498        )
1499        .unwrap();
1500
1501        assert_eq!(response["ticket"]["flow"], "default");
1502        assert!(
1503            std::fs::read_to_string(&path)
1504                .unwrap()
1505                .contains("flow: default")
1506        );
1507    }
1508
1509    #[test]
1510    fn an_explicit_flow_is_honored() {
1511        let (root, store) = world();
1512        std::fs::write(
1513            root.path().join(".agents/sloop/tickets/t.md"),
1514            ticket("flow: release\n", "# T\n"),
1515        )
1516        .unwrap();
1517        let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1518
1519        let response = handle(
1520            root.path(),
1521            &store,
1522            &args,
1523            2_000,
1524            "TICK",
1525            None,
1526            &flows(),
1527            "default",
1528        )
1529        .unwrap();
1530
1531        assert_eq!(response["ticket"]["flow"], "release");
1532    }
1533
1534    #[test]
1535    fn a_stamped_flow_mismatching_the_request_is_a_conflict() {
1536        let (root, store) = world();
1537        std::fs::write(
1538            root.path().join(".agents/sloop/tickets/t.md"),
1539            ticket("flow: release\n", "# T\n"),
1540        )
1541        .unwrap();
1542        let args = PostArgs {
1543            file: ".agents/sloop/tickets/t.md".into(),
1544            project: None,
1545            flow: Some("default".into()),
1546            trigger: PostTrigger::Manual,
1547        };
1548
1549        assert!(matches!(
1550            handle(
1551                root.path(),
1552                &store,
1553                &args,
1554                2_000,
1555                "TICK",
1556                None,
1557                &flows(),
1558                "default"
1559            ),
1560            Err(PostError::FlowConflict { .. })
1561        ));
1562    }
1563
1564    #[test]
1565    fn an_unknown_flow_is_rejected_and_names_known_flows() {
1566        let (root, store) = world();
1567        std::fs::write(
1568            root.path().join(".agents/sloop/tickets/t.md"),
1569            ticket("flow: bogus\n", "# T\n"),
1570        )
1571        .unwrap();
1572        let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1573
1574        let error = handle(
1575            root.path(),
1576            &store,
1577            &args,
1578            2_000,
1579            "TICK",
1580            None,
1581            &flows(),
1582            "default",
1583        )
1584        .unwrap_err()
1585        .to_string();
1586        assert!(error.contains("bogus"), "{error}");
1587        assert!(error.contains("default"), "{error}");
1588        assert!(error.contains("release"), "{error}");
1589        assert!(store.ticket_ids().unwrap().is_empty());
1590    }
1591
1592    #[test]
1593    fn reindex_recovers_the_flow_binding_from_frontmatter_into_a_fresh_store() {
1594        let (root, store) = world();
1595        std::fs::write(
1596            root.path().join(".agents/sloop/tickets/t.md"),
1597            ticket("", "# T\n"),
1598        )
1599        .unwrap();
1600        let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1601        handle(
1602            root.path(),
1603            &store,
1604            &args,
1605            2_000,
1606            "TICK",
1607            None,
1608            &flows(),
1609            "default",
1610        )
1611        .unwrap();
1612        drop(store);
1613
1614        let fresh_store =
1615            LocalSqlite::from_db(Db::open(&root.path().join("fresh.db"), 3_000).unwrap());
1616        fresh_store
1617            .upsert_local_project(
1618                "default",
1619                ".agents/sloop/projects/default.md",
1620                "Default",
1621                3_000,
1622            )
1623            .unwrap();
1624        let response = handle(
1625            root.path(),
1626            &fresh_store,
1627            &args,
1628            3_000,
1629            "TICK",
1630            None,
1631            &flows(),
1632            "default",
1633        )
1634        .unwrap();
1635
1636        assert_eq!(response["ticket"]["id"], "TICK-1");
1637        assert_eq!(response["ticket"]["flow"], "default");
1638    }
1639
1640    #[test]
1641    fn idless_tickets_get_monotonic_generated_ids() {
1642        let (root, store) = world();
1643        std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1644        std::fs::write(
1645            root.path().join(".agents/sloop/tickets/fix.md"),
1646            ticket("", "# A\n"),
1647        )
1648        .unwrap();
1649        std::fs::write(
1650            root.path().join(".agents/sloop/tickets/nested/fix.md"),
1651            ticket("", "# B\n"),
1652        )
1653        .unwrap();
1654
1655        let first = handle(
1656            root.path(),
1657            &store,
1658            &post(".agents/sloop/tickets/fix.md", PostTrigger::Manual),
1659            2_000,
1660            "TICK",
1661            None,
1662            &flows(),
1663            "default",
1664        )
1665        .unwrap();
1666        let second = handle(
1667            root.path(),
1668            &store,
1669            &post(".agents/sloop/tickets/nested/fix.md", PostTrigger::Manual),
1670            2_100,
1671            "TICK",
1672            None,
1673            &flows(),
1674            "default",
1675        )
1676        .unwrap();
1677        assert_eq!(first["ticket"]["id"], "TICK-1");
1678        assert_eq!(second["ticket"]["id"], "TICK-2");
1679    }
1680
1681    #[test]
1682    fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1683        let (root, store) = world();
1684        let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1685        let explicit_content = ticket(
1686            "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1687            "# Explicit\n",
1688        );
1689        std::fs::write(&explicit, &explicit_content).unwrap();
1690        handle(
1691            root.path(),
1692            &store,
1693            &post(".agents/sloop/tickets/explicit.md", PostTrigger::Manual),
1694            2_000,
1695            "WORK",
1696            None,
1697            &flows(),
1698            "default",
1699        )
1700        .unwrap();
1701        assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1702
1703        std::fs::write(
1704            root.path().join(".agents/sloop/tickets/unrelated.md"),
1705            ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1706        )
1707        .unwrap();
1708        handle(
1709            root.path(),
1710            &store,
1711            &post(".agents/sloop/tickets/unrelated.md", PostTrigger::Manual),
1712            2_100,
1713            "WORK",
1714            None,
1715            &flows(),
1716            "default",
1717        )
1718        .unwrap();
1719
1720        std::fs::write(
1721            root.path().join(".agents/sloop/tickets/generated.md"),
1722            ticket("", "# Generated\n"),
1723        )
1724        .unwrap();
1725        let generated = handle(
1726            root.path(),
1727            &store,
1728            &post(".agents/sloop/tickets/generated.md", PostTrigger::Manual),
1729            2_200,
1730            "WORK",
1731            None,
1732            &flows(),
1733            "default",
1734        )
1735        .unwrap();
1736        assert_eq!(generated["ticket"]["id"], "WORK-10");
1737    }
1738
1739    #[test]
1740    fn paths_escaping_the_repository_are_rejected() {
1741        let (root, store) = world();
1742        let args = post("../outside.md", PostTrigger::Manual);
1743
1744        assert!(matches!(
1745            handle(
1746                root.path(),
1747                &store,
1748                &args,
1749                2_000,
1750                "TICK",
1751                None,
1752                &flows(),
1753                "default"
1754            ),
1755            Err(PostError::OutsideRepository(_))
1756        ));
1757    }
1758
1759    /// The repository root is always canonical in production, so an absolute
1760    /// path reaching the same file through a symlink has to resolve to it too.
1761    /// This is the normal case on macOS, where the temporary directory these
1762    /// tests run in is itself reached through a link into `/private`.
1763    #[test]
1764    fn absolute_paths_through_a_symlinked_root_stay_inside_the_repository() {
1765        let (root, store) = world();
1766        let link = tempdir().unwrap();
1767        let linked_root = link.path().join("repository");
1768        std::os::unix::fs::symlink(root.path(), &linked_root).unwrap();
1769
1770        let ticket = linked_root.join(".agents/sloop/tickets/linked.md");
1771        std::fs::write(&ticket, "---\nname: Linked\nblocked_by: []\n---\n\nBody\n").unwrap();
1772
1773        let posted = handle(
1774            root.path(),
1775            &store,
1776            &post(ticket.to_str().unwrap(), PostTrigger::Manual),
1777            2_000,
1778            "TICK",
1779            None,
1780            &flows(),
1781            "default",
1782        )
1783        .unwrap();
1784        assert_eq!(posted["ticket"]["name"], "Linked");
1785    }
1786
1787    /// A lexical check accepts a link sitting in the ticket directory and then
1788    /// reads whatever it points at. Containment is about the bytes that get
1789    /// read, so the target decides.
1790    #[test]
1791    fn ticket_files_symlinked_out_of_the_repository_are_rejected() {
1792        let (root, store) = world();
1793        let outside = tempdir().unwrap();
1794        let target = outside.path().join("elsewhere.md");
1795        std::fs::write(
1796            &target,
1797            "---\nname: Elsewhere\nblocked_by: []\n---\n\nBody\n",
1798        )
1799        .unwrap();
1800        std::os::unix::fs::symlink(&target, root.path().join(".agents/sloop/tickets/escape.md"))
1801            .unwrap();
1802
1803        assert!(matches!(
1804            handle(
1805                root.path(),
1806                &store,
1807                &post(".agents/sloop/tickets/escape.md", PostTrigger::Manual),
1808                2_000,
1809                "TICK",
1810                None,
1811                &flows(),
1812                "default",
1813            ),
1814            Err(PostError::OutsideRepository(_))
1815        ));
1816    }
1817
1818    /// Containment is decided before the file is read, so resolution must not
1819    /// turn a missing ticket into a containment failure.
1820    #[test]
1821    fn missing_ticket_files_inside_the_directory_still_report_not_found() {
1822        let (root, store) = world();
1823
1824        assert!(matches!(
1825            handle(
1826                root.path(),
1827                &store,
1828                &post(".agents/sloop/tickets/absent.md", PostTrigger::Manual),
1829                2_000,
1830                "TICK",
1831                None,
1832                &flows(),
1833                "default",
1834            ),
1835            Err(PostError::TicketFileNotFound(_))
1836        ));
1837    }
1838
1839    #[test]
1840    fn paths_outside_the_ticket_directory_are_rejected() {
1841        let (root, store) = world();
1842        std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1843
1844        assert!(matches!(
1845            handle(
1846                root.path(),
1847                &store,
1848                &post("elsewhere.md", PostTrigger::Manual),
1849                2_000,
1850                "TICK",
1851                None,
1852                &flows(),
1853                "default",
1854            ),
1855            Err(PostError::OutsideTicketDirectory { .. })
1856        ));
1857    }
1858}