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