Skip to main content

sloop/
post.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::{Component, Path, PathBuf};
6
7use serde_json::{Value, json};
8
9use crate::config::{AgentConfig, expand_agent_cmd};
10use crate::domain::ticket::TicketState;
11use crate::flow::Flow;
12use crate::frontmatter::{self, FrontmatterError};
13use crate::ids::{IdError, next_id};
14use crate::protocol::{PostActivation, PostArgs};
15use crate::store::{ActivationKind, NewActivation, Store, StoreError};
16
17/// Registers a ticket file: validates and stamps frontmatter, indexes the
18/// ticket, and for `auto` and `at` creates one queued activation. Reposting
19/// a stamped file is idempotent; reposting with a different `--at` time
20/// reschedules the queued activation. The dispatcher is the only caller and
21/// computes `at_eligible_ms` from its injected clock, so plain reads before
22/// writes here cannot race another writer.
23#[allow(clippy::too_many_arguments)]
24pub fn handle(
25    root: &Path,
26    ticket_dir: &Path,
27    store: &Store,
28    args: &PostArgs,
29    now_ms: i64,
30    at_eligible_ms: Option<i64>,
31    ticket_prefix: &str,
32    agent: Option<&AgentConfig>,
33    flows: &BTreeMap<String, Flow>,
34    default_flow: &str,
35) -> Result<Value, PostError> {
36    let initial_state = match args.activation {
37        PostActivation::Hold => TicketState::Held,
38        _ => TicketState::Ready,
39    };
40    let relative = repository_relative(root, ticket_dir, &args.file)?;
41    let relative_str = relative.to_string_lossy().into_owned();
42    let absolute = root.join(&relative);
43    let content = fs::read_to_string(&absolute).map_err(|source| {
44        if source.kind() == io::ErrorKind::NotFound {
45            PostError::TicketFileNotFound(relative_str.clone())
46        } else {
47            PostError::Io {
48                path: relative_str.clone(),
49                source,
50            }
51        }
52    })?;
53    let stamped = parse_ticket_frontmatter(&content, &relative_str)?;
54
55    let project = match (stamped.project.as_deref(), args.project.as_deref()) {
56        (Some(stamped), Some(requested)) if stamped != requested => {
57            return Err(PostError::ProjectConflict {
58                path: relative_str,
59                stamped: stamped.into(),
60                requested: requested.into(),
61            });
62        }
63        (Some(stamped), _) => stamped.to_owned(),
64        (None, Some(requested)) => requested.to_owned(),
65        (None, None) => "default".to_owned(),
66    };
67    if !store.project_exists(&project)? {
68        return Err(PostError::UnknownProject(project));
69    }
70
71    let flow_name = match (stamped.flow.as_deref(), args.flow.as_deref()) {
72        (Some(stamped), Some(requested)) if stamped != requested => {
73            return Err(PostError::FlowConflict {
74                path: relative_str,
75                stamped: stamped.into(),
76                requested: requested.into(),
77            });
78        }
79        (Some(stamped), _) => stamped.to_owned(),
80        (None, Some(requested)) => requested.to_owned(),
81        (None, None) => default_flow.to_owned(),
82    };
83    if !flows.contains_key(&flow_name) {
84        let mut known: Vec<&str> = flows.keys().map(String::as_str).collect();
85        known.sort_unstable();
86        return Err(PostError::UnknownFlow {
87            flow: flow_name,
88            known: known.into_iter().map(str::to_owned).collect(),
89        });
90    }
91
92    let target = match stamped.target.as_deref() {
93        Some(target) if agent.is_some_and(|agent| agent.targets.contains_key(target)) => {
94            Some(target.to_owned())
95        }
96        Some(target) => return Err(PostError::UnknownTarget(target.to_owned())),
97        None => agent.map(|agent| agent.default_target.clone()),
98    };
99    if let (Some(agent), Some(target)) = (agent, target.as_deref()) {
100        let command = agent
101            .targets
102            .get(target)
103            .expect("configured default target was validated");
104        expand_agent_cmd(
105            command,
106            stamped.model.as_deref(),
107            stamped.effort.as_deref(),
108            "",
109        )
110        .map_err(|message| PostError::MissingTargetValue {
111            target: target.to_owned(),
112            message,
113        })?;
114    }
115
116    let (ticket_id, existing) = match stamped.id.as_deref() {
117        Some(id) => {
118            if let Some(existing) = store.ticket(id)? {
119                if existing.file_path.as_deref() != Some(relative_str.as_str()) {
120                    return Err(PostError::TicketIdTaken {
121                        id: id.to_owned(),
122                        file: existing.file_path.unwrap_or_default(),
123                    });
124                }
125                if existing.project_id != project {
126                    return Err(PostError::ProjectConflict {
127                        path: relative_str,
128                        stamped: project,
129                        requested: existing.project_id,
130                    });
131                }
132                (id.to_owned(), Some(existing))
133            } else {
134                (id.to_owned(), None)
135            }
136        }
137        None => (allocate_ticket_id(store, ticket_prefix)?, None),
138    };
139    for blocker in &stamped.blocked_by {
140        if blocker != &ticket_id && store.ticket(blocker)?.is_none() {
141            return Err(PostError::UnknownBlockedBy {
142                ticket: ticket_id.clone(),
143                blocker: blocker.clone(),
144            });
145        }
146    }
147    let mut dependencies = store.ticket_dependencies()?;
148    dependencies.insert(ticket_id.clone(), stamped.blocked_by.clone());
149    if let Some(chain) = crate::domain::graph::find_cycle(&dependencies) {
150        return Err(PostError::DependencyCycle(chain));
151    }
152
153    let worktree = match stamped.worktree.clone() {
154        Some(worktree) => worktree,
155        None => {
156            let stem = Path::new(&relative_str)
157                .file_stem()
158                .and_then(|stem| stem.to_str());
159            crate::ids::default_worktree(stem, &ticket_id).map_err(|reason| {
160                PostError::InvalidWorktreeStem {
161                    path: relative_str.clone(),
162                    reason,
163                }
164            })?
165        }
166    };
167    if existing.is_some() {
168        store.update_local_ticket(
169            &ticket_id,
170            &stamped.name,
171            &stamped.blocked_by,
172            &worktree,
173            target.as_deref(),
174            stamped.model.as_deref(),
175            stamped.effort.as_deref(),
176            &flow_name,
177            now_ms,
178        )?;
179    } else {
180        store.insert_local_ticket(
181            &ticket_id,
182            &project,
183            &relative_str,
184            &stamped.name,
185            &stamped.blocked_by,
186            &worktree,
187            target.as_deref(),
188            stamped.model.as_deref(),
189            stamped.effort.as_deref(),
190            &flow_name,
191            initial_state,
192            now_ms,
193        )?;
194    }
195    store.update_ticket_body(
196        &ticket_id,
197        frontmatter::body(&content).expect("validated frontmatter has a body"),
198        now_ms,
199    )?;
200    let ticket = store
201        .ticket(&ticket_id)?
202        .expect("registered ticket still exists");
203
204    if let Some(updated) = frontmatter::stamp(&content, &ticket.id, &project, &worktree, &flow_name)
205        .map_err(|error| PostError::InvalidTicket {
206            path: relative_str.clone(),
207            error,
208        })?
209    {
210        fs::write(&absolute, updated).map_err(|source| PostError::Io {
211            path: relative_str.clone(),
212            source,
213        })?;
214    }
215
216    let activation = match &args.activation {
217        PostActivation::Manual | PostActivation::Hold => Value::Null,
218        PostActivation::Auto => {
219            queue_activation(store, &ticket.id, ActivationKind::Auto, None, now_ms)?
220        }
221        PostActivation::At { .. } => {
222            let eligible_at_ms =
223                at_eligible_ms.expect("the dispatcher computes eligibility for at activations");
224            queue_activation(
225                store,
226                &ticket.id,
227                ActivationKind::At,
228                Some(eligible_at_ms),
229                now_ms,
230            )?
231        }
232    };
233
234    Ok(json!({
235        "ticket": {
236            "id": ticket.id,
237            "project": project,
238            "file": relative_str,
239            "state": ticket.state,
240            "name": ticket.name,
241            "blocked_by": ticket.blocked_by,
242            "worktree": ticket.worktree,
243            "target": ticket.target,
244            "model": ticket.model,
245            "effort": ticket.effort,
246            "flow": ticket.flow,
247        },
248        "activation": activation,
249    }))
250}
251
252pub(crate) fn parse_ticket_frontmatter(
253    content: &str,
254    path: &str,
255) -> Result<frontmatter::Frontmatter, PostError> {
256    let stamped = frontmatter::parse(content).map_err(|error| match error {
257        FrontmatterError::InvalidBlockedBy => PostError::InvalidBlockedBy {
258            path: path.to_owned(),
259        },
260        error => PostError::InvalidTicket {
261            path: path.to_owned(),
262            error,
263        },
264    })?;
265    if stamped.name.trim().is_empty() {
266        return Err(PostError::MissingName {
267            path: path.to_owned(),
268        });
269    }
270    if !stamped.has_blocked_by() {
271        return Err(PostError::MissingBlockedBy {
272            path: path.to_owned(),
273        });
274    }
275    if frontmatter::body(content)
276        .expect("frontmatter was already parsed")
277        .trim()
278        .is_empty()
279    {
280        return Err(PostError::EmptyBody {
281            path: path.to_owned(),
282        });
283    }
284    Ok(stamped)
285}
286
287/// Reuses an existing queued activation of the same kind so reposting cannot
288/// enqueue duplicate work. A timed repost moves the queued activation to the
289/// newly requested instant instead of keeping the stale one.
290fn queue_activation(
291    store: &Store,
292    ticket_id: &str,
293    kind: ActivationKind,
294    eligible_at_ms: Option<i64>,
295    now_ms: i64,
296) -> Result<Value, PostError> {
297    let id = match store.queued_ticket_activation(ticket_id, kind)? {
298        Some(id) => {
299            if let Some(eligible_at_ms) = eligible_at_ms {
300                store.reschedule_activation(&id, eligible_at_ms, now_ms)?;
301            }
302            id
303        }
304        None => {
305            let id = format!("A{}", store.next_activation_ordinal()?);
306            store.insert_activation(
307                &NewActivation {
308                    id: &id,
309                    kind,
310                    ticket_id: Some(ticket_id),
311                    project_id: None,
312                    eligible_at_ms,
313                    interval_ms: None,
314                },
315                now_ms,
316            )?;
317            id
318        }
319    };
320    let mut activation = json!({
321        "id": id,
322        "kind": kind.as_str(),
323        "state": "queued",
324        "ticket": ticket_id,
325    });
326    if let Some(eligible_at_ms) = eligible_at_ms {
327        activation["eligible_at_ms"] = json!(eligible_at_ms);
328    }
329    Ok(activation)
330}
331
332fn allocate_ticket_id(store: &Store, prefix: &str) -> Result<String, PostError> {
333    let ids = store.ticket_ids()?;
334    next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
335}
336
337/// Resolves the request path against the repository root and requires the
338/// result to stay inside the committed Sloop ticket directory.
339fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
340    let path = Path::new(file);
341    let joined = if path.is_absolute() {
342        path.to_path_buf()
343    } else {
344        root.join(path)
345    };
346
347    let mut normalized = PathBuf::new();
348    for component in joined.components() {
349        match component {
350            Component::CurDir => {}
351            Component::ParentDir => {
352                if !normalized.pop() {
353                    return Err(PostError::OutsideRepository(file.to_owned()));
354                }
355            }
356            component => normalized.push(component),
357        }
358    }
359    let relative = normalized
360        .strip_prefix(root)
361        .map(Path::to_path_buf)
362        .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
363    if !relative.starts_with(ticket_dir) {
364        return Err(PostError::OutsideTicketDirectory {
365            path: file.to_owned(),
366            directory: ticket_dir.to_path_buf(),
367        });
368    }
369    Ok(relative)
370}
371
372#[derive(Debug)]
373pub enum PostError {
374    TicketFileNotFound(String),
375    OutsideRepository(String),
376    OutsideTicketDirectory {
377        path: String,
378        directory: PathBuf,
379    },
380    InvalidTicket {
381        path: String,
382        error: FrontmatterError,
383    },
384    MissingName {
385        path: String,
386    },
387    InvalidWorktreeStem {
388        path: String,
389        reason: String,
390    },
391    MissingBlockedBy {
392        path: String,
393    },
394    InvalidBlockedBy {
395        path: String,
396    },
397    EmptyBody {
398        path: String,
399    },
400    UnknownBlockedBy {
401        ticket: String,
402        blocker: String,
403    },
404    DependencyCycle(Vec<String>),
405    UnknownProject(String),
406    UnknownTarget(String),
407    MissingTargetValue {
408        target: String,
409        message: String,
410    },
411    ProjectConflict {
412        path: String,
413        stamped: String,
414        requested: String,
415    },
416    FlowConflict {
417        path: String,
418        stamped: String,
419        requested: String,
420    },
421    UnknownFlow {
422        flow: String,
423        known: Vec<String>,
424    },
425    TicketIdTaken {
426        id: String,
427        file: String,
428    },
429    Io {
430        path: String,
431        source: io::Error,
432    },
433    Store(StoreError),
434    IdAllocation(IdError),
435}
436
437impl From<StoreError> for PostError {
438    fn from(error: StoreError) -> Self {
439        Self::Store(error)
440    }
441}
442
443impl fmt::Display for PostError {
444    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
445        match self {
446            Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
447            Self::OutsideRepository(path) => {
448                write!(formatter, "`{path}` is outside the repository")
449            }
450            Self::OutsideTicketDirectory { path, directory } => write!(
451                formatter,
452                "`{path}` is outside the {} directory",
453                directory.display()
454            ),
455            Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
456            Self::MissingName { path } => write!(
457                formatter,
458                "{path}: missing or empty `name`; add `name: Your ticket title`"
459            ),
460            Self::InvalidWorktreeStem { path, reason } => {
461                write!(formatter, "{path}: {reason}")
462            }
463            Self::MissingBlockedBy { path } => write!(
464                formatter,
465                "{path}: missing `blocked_by`; add `blocked_by: []` if there are no dependencies"
466            ),
467            Self::InvalidBlockedBy { path } => write!(
468                formatter,
469                "{path}: invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs"
470            ),
471            Self::EmptyBody { path } => write!(
472                formatter,
473                "{path}: empty `body`; add a ticket description after the frontmatter"
474            ),
475            Self::UnknownBlockedBy { ticket, blocker } => write!(
476                formatter,
477                "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
478            ),
479            Self::DependencyCycle(chain) => write!(
480                formatter,
481                "field `blocked_by` creates a dependency cycle: {}",
482                chain.join(" -> ")
483            ),
484            Self::UnknownProject(project) => {
485                write!(formatter, "project `{project}` is not indexed")
486            }
487            Self::UnknownTarget(target) => {
488                write!(formatter, "agent target `{target}` is not configured")
489            }
490            Self::MissingTargetValue { target, message } => {
491                write!(formatter, "ticket using agent target `{target}` {message}")
492            }
493            Self::ProjectConflict {
494                path,
495                stamped,
496                requested,
497            } => write!(
498                formatter,
499                "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
500            ),
501            Self::FlowConflict {
502                path,
503                stamped,
504                requested,
505            } => write!(
506                formatter,
507                "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
508            ),
509            Self::UnknownFlow { flow, known } => write!(
510                formatter,
511                "flow `{flow}` is not defined; known flows: {}",
512                known.join(", ")
513            ),
514            Self::TicketIdTaken { id, file } => write!(
515                formatter,
516                "ticket ID `{id}` is already registered by `{file}`"
517            ),
518            Self::Io { path, source } => write!(formatter, "{path}: {source}"),
519            Self::Store(error) => error.fmt(formatter),
520            Self::IdAllocation(error) => error.fmt(formatter),
521        }
522    }
523}
524
525impl std::error::Error for PostError {}
526
527#[cfg(test)]
528mod tests {
529    use std::collections::BTreeMap;
530
531    use tempfile::tempdir;
532
533    use super::{PostError, handle as handle_with_directory};
534    use crate::config::{AgentConfig, AgentTarget};
535    use crate::flow::{Flow, Stage, StageKind, VerdictPolicy};
536    use crate::protocol::{PostActivation, PostArgs};
537    use crate::store::Store;
538
539    fn world() -> (tempfile::TempDir, Store) {
540        let root = tempdir().unwrap();
541        std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
542        let store = Store::open(&root.path().join("sloop.db"), 1_000).unwrap();
543        store
544            .upsert_local_project(
545                "default",
546                ".agents/sloop/projects/default.md",
547                "Default",
548                1_000,
549            )
550            .unwrap();
551        (root, store)
552    }
553
554    #[allow(clippy::too_many_arguments)]
555    fn handle(
556        root: &std::path::Path,
557        store: &Store,
558        args: &PostArgs,
559        now_ms: i64,
560        ticket_prefix: &str,
561        agent: Option<&AgentConfig>,
562        flows: &BTreeMap<String, Flow>,
563        default_flow: &str,
564    ) -> Result<serde_json::Value, PostError> {
565        handle_with_directory(
566            root,
567            std::path::Path::new(".agents/sloop/tickets"),
568            store,
569            args,
570            now_ms,
571            None,
572            ticket_prefix,
573            agent,
574            flows,
575            default_flow,
576        )
577    }
578
579    fn handle_at(
580        root: &std::path::Path,
581        store: &Store,
582        args: &PostArgs,
583        now_ms: i64,
584        at_eligible_ms: i64,
585    ) -> Result<serde_json::Value, PostError> {
586        handle_with_directory(
587            root,
588            std::path::Path::new(".agents/sloop/tickets"),
589            store,
590            args,
591            now_ms,
592            Some(at_eligible_ms),
593            "TICK",
594            None,
595            &flows(),
596            "default",
597        )
598    }
599
600    fn post(file: &str, activation: PostActivation) -> PostArgs {
601        PostArgs {
602            file: file.into(),
603            project: None,
604            flow: None,
605            activation,
606        }
607    }
608
609    fn flows() -> BTreeMap<String, Flow> {
610        BTreeMap::from([
611            (
612                "default".to_owned(),
613                Flow {
614                    name: "default".into(),
615                    stages: vec![Stage {
616                        name: "build".into(),
617                        kind: StageKind::Agent,
618                        verdict: VerdictPolicy::Commits,
619                        on_fail: None,
620                    }],
621                },
622            ),
623            (
624                "release".to_owned(),
625                Flow {
626                    name: "release".into(),
627                    stages: vec![Stage {
628                        name: "build".into(),
629                        kind: StageKind::Agent,
630                        verdict: VerdictPolicy::Commits,
631                        on_fail: None,
632                    }],
633                },
634            ),
635        ])
636    }
637
638    fn ticket(frontmatter: &str, body: &str) -> String {
639        format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
640    }
641
642    fn agent() -> AgentConfig {
643        AgentConfig {
644            default_target: "claude".into(),
645            targets: BTreeMap::from([
646                (
647                    "claude".into(),
648                    AgentTarget {
649                        cmd: vec!["claude".into(), "{prompt}".into()],
650                        model: None,
651                        effort: None,
652                    },
653                ),
654                (
655                    "codex".into(),
656                    AgentTarget {
657                        cmd: vec![
658                            "codex".into(),
659                            "{model}".into(),
660                            "{effort}".into(),
661                            "{prompt}".into(),
662                        ],
663                        model: None,
664                        effort: None,
665                    },
666                ),
667            ]),
668        }
669    }
670
671    #[test]
672    fn posting_twice_reuses_the_registration_and_activation() {
673        let (root, store) = world();
674        std::fs::write(
675            root.path().join(".agents/sloop/tickets/cooldown.md"),
676            ticket("", "# Cooldowns\n"),
677        )
678        .unwrap();
679        let args = post(".agents/sloop/tickets/cooldown.md", PostActivation::Auto);
680
681        let first = handle(
682            root.path(),
683            &store,
684            &args,
685            2_000,
686            "TICK",
687            None,
688            &flows(),
689            "default",
690        )
691        .unwrap();
692        let second = handle(
693            root.path(),
694            &store,
695            &args,
696            3_000,
697            "TICK",
698            None,
699            &flows(),
700            "default",
701        )
702        .unwrap();
703        assert_eq!(first["ticket"]["id"], second["ticket"]["id"]);
704        assert_eq!(first["activation"]["id"], second["activation"]["id"]);
705    }
706
707    #[test]
708    fn posting_at_queues_a_timed_activation_and_reposting_reschedules_it() {
709        let (root, store) = world();
710        std::fs::write(
711            root.path().join(".agents/sloop/tickets/timed.md"),
712            ticket("", "# Timed\n"),
713        )
714        .unwrap();
715        let args = post(
716            ".agents/sloop/tickets/timed.md",
717            PostActivation::At {
718                time: "03:00".into(),
719            },
720        );
721
722        let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
723        assert_eq!(first["ticket"]["state"], "ready");
724        assert_eq!(first["activation"]["kind"], "at");
725        assert_eq!(first["activation"]["eligible_at_ms"], 10_000);
726
727        let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
728        assert_eq!(second["activation"]["id"], first["activation"]["id"]);
729        assert_eq!(second["activation"]["eligible_at_ms"], 20_000);
730
731        let queued = store.queued_activations().unwrap();
732        assert_eq!(queued.len(), 1);
733        assert_eq!(queued[0].eligible_at_ms, Some(20_000));
734    }
735
736    #[test]
737    fn posting_snapshots_the_default_target_and_reposting_refreshes_execution_values() {
738        let (root, store) = world();
739        let path = root.path().join(".agents/sloop/tickets/work.md");
740        std::fs::write(&path, ticket("model: sonnet\neffort: medium\n", "# Work\n")).unwrap();
741        let args = post(".agents/sloop/tickets/work.md", PostActivation::Manual);
742        let agent = agent();
743
744        let first = handle(
745            root.path(),
746            &store,
747            &args,
748            2_000,
749            "TICK",
750            Some(&agent),
751            &flows(),
752            "default",
753        )
754        .unwrap();
755        assert_eq!(first["ticket"]["target"], "claude");
756
757        std::fs::write(
758            &path,
759            ticket(
760                "id: TICK-1\nproject: default\ntarget: codex\nmodel: o3\neffort: high\n",
761                "# Work\n",
762            ),
763        )
764        .unwrap();
765        let second = handle(
766            root.path(),
767            &store,
768            &args,
769            3_000,
770            "TICK",
771            Some(&agent),
772            &flows(),
773            "default",
774        )
775        .unwrap();
776        assert_eq!(second["ticket"]["id"], first["ticket"]["id"]);
777        assert_eq!(second["ticket"]["target"], "codex");
778        assert_eq!(second["ticket"]["model"], "o3");
779        assert_eq!(second["ticket"]["effort"], "high");
780    }
781
782    #[test]
783    fn unknown_targets_are_rejected_before_registration_or_activation() {
784        let (root, store) = world();
785        std::fs::write(
786            root.path().join(".agents/sloop/tickets/work.md"),
787            ticket("target: missing\n", "# Work\n"),
788        )
789        .unwrap();
790        let args = post(".agents/sloop/tickets/work.md", PostActivation::Auto);
791
792        assert!(matches!(
793            handle(root.path(), &store, &args, 2_000, "TICK", Some(&agent()), &flows(), "default"),
794            Err(PostError::UnknownTarget(target)) if target == "missing"
795        ));
796        assert!(store.ticket_ids().unwrap().is_empty());
797        assert!(store.queued_activations().unwrap().is_empty());
798    }
799
800    #[test]
801    fn selected_target_placeholders_require_ticket_values_before_registration() {
802        let (root, store) = world();
803        std::fs::write(
804            root.path().join(".agents/sloop/tickets/work.md"),
805            ticket("target: codex\neffort: high\n", "# Work\n"),
806        )
807        .unwrap();
808        let args = post(".agents/sloop/tickets/work.md", PostActivation::Manual);
809
810        let error = handle(
811            root.path(),
812            &store,
813            &args,
814            2_000,
815            "TICK",
816            Some(&agent()),
817            &flows(),
818            "default",
819        )
820        .unwrap_err()
821        .to_string();
822        assert!(error.contains("agent target `codex`"), "{error}");
823        assert!(error.contains("does not specify `model`"), "{error}");
824        assert!(store.ticket_ids().unwrap().is_empty());
825    }
826
827    #[test]
828    fn a_stamped_project_mismatching_the_request_is_a_conflict() {
829        let (root, store) = world();
830        std::fs::write(
831            root.path().join(".agents/sloop/tickets/t.md"),
832            ticket("id: T1\nproject: default\n", "# Work\n"),
833        )
834        .unwrap();
835        let args = PostArgs {
836            file: ".agents/sloop/tickets/t.md".into(),
837            project: Some("other".into()),
838            flow: None,
839            activation: PostActivation::Manual,
840        };
841
842        assert!(matches!(
843            handle(
844                root.path(),
845                &store,
846                &args,
847                2_000,
848                "TICK",
849                None,
850                &flows(),
851                "default"
852            ),
853            Err(PostError::ProjectConflict { .. })
854        ));
855    }
856
857    #[test]
858    fn an_unknown_project_is_rejected() {
859        let (root, store) = world();
860        std::fs::write(
861            root.path().join(".agents/sloop/tickets/t.md"),
862            ticket("", "# T\n"),
863        )
864        .unwrap();
865        let args = PostArgs {
866            file: ".agents/sloop/tickets/t.md".into(),
867            project: Some("missing".into()),
868            flow: None,
869            activation: PostActivation::Manual,
870        };
871
872        assert!(matches!(
873            handle(root.path(), &store, &args, 2_000, "TICK", None, &flows(), "default"),
874            Err(PostError::UnknownProject(project)) if project == "missing"
875        ));
876    }
877
878    #[test]
879    fn a_missing_flow_is_stamped_with_the_default() {
880        let (root, store) = world();
881        let path = root.path().join(".agents/sloop/tickets/t.md");
882        std::fs::write(&path, ticket("", "# T\n")).unwrap();
883        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
884
885        let response = handle(
886            root.path(),
887            &store,
888            &args,
889            2_000,
890            "TICK",
891            None,
892            &flows(),
893            "default",
894        )
895        .unwrap();
896
897        assert_eq!(response["ticket"]["flow"], "default");
898        assert!(
899            std::fs::read_to_string(&path)
900                .unwrap()
901                .contains("flow: default")
902        );
903    }
904
905    #[test]
906    fn an_explicit_flow_is_honored() {
907        let (root, store) = world();
908        std::fs::write(
909            root.path().join(".agents/sloop/tickets/t.md"),
910            ticket("flow: release\n", "# T\n"),
911        )
912        .unwrap();
913        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
914
915        let response = handle(
916            root.path(),
917            &store,
918            &args,
919            2_000,
920            "TICK",
921            None,
922            &flows(),
923            "default",
924        )
925        .unwrap();
926
927        assert_eq!(response["ticket"]["flow"], "release");
928    }
929
930    #[test]
931    fn a_stamped_flow_mismatching_the_request_is_a_conflict() {
932        let (root, store) = world();
933        std::fs::write(
934            root.path().join(".agents/sloop/tickets/t.md"),
935            ticket("flow: release\n", "# T\n"),
936        )
937        .unwrap();
938        let args = PostArgs {
939            file: ".agents/sloop/tickets/t.md".into(),
940            project: None,
941            flow: Some("default".into()),
942            activation: PostActivation::Manual,
943        };
944
945        assert!(matches!(
946            handle(
947                root.path(),
948                &store,
949                &args,
950                2_000,
951                "TICK",
952                None,
953                &flows(),
954                "default"
955            ),
956            Err(PostError::FlowConflict { .. })
957        ));
958    }
959
960    #[test]
961    fn an_unknown_flow_is_rejected_and_names_known_flows() {
962        let (root, store) = world();
963        std::fs::write(
964            root.path().join(".agents/sloop/tickets/t.md"),
965            ticket("flow: bogus\n", "# T\n"),
966        )
967        .unwrap();
968        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
969
970        let error = handle(
971            root.path(),
972            &store,
973            &args,
974            2_000,
975            "TICK",
976            None,
977            &flows(),
978            "default",
979        )
980        .unwrap_err()
981        .to_string();
982        assert!(error.contains("bogus"), "{error}");
983        assert!(error.contains("default"), "{error}");
984        assert!(error.contains("release"), "{error}");
985        assert!(store.ticket_ids().unwrap().is_empty());
986    }
987
988    #[test]
989    fn reindex_recovers_the_flow_binding_from_frontmatter_into_a_fresh_store() {
990        let (root, store) = world();
991        std::fs::write(
992            root.path().join(".agents/sloop/tickets/t.md"),
993            ticket("", "# T\n"),
994        )
995        .unwrap();
996        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
997        handle(
998            root.path(),
999            &store,
1000            &args,
1001            2_000,
1002            "TICK",
1003            None,
1004            &flows(),
1005            "default",
1006        )
1007        .unwrap();
1008        drop(store);
1009
1010        // A fresh store with no rows of its own must recover the flow binding
1011        // purely from the committed frontmatter that the first post stamped.
1012        let fresh_store = Store::open(&root.path().join("fresh.db"), 3_000).unwrap();
1013        fresh_store
1014            .upsert_local_project(
1015                "default",
1016                ".agents/sloop/projects/default.md",
1017                "Default",
1018                3_000,
1019            )
1020            .unwrap();
1021        let response = handle(
1022            root.path(),
1023            &fresh_store,
1024            &args,
1025            3_000,
1026            "TICK",
1027            None,
1028            &flows(),
1029            "default",
1030        )
1031        .unwrap();
1032
1033        assert_eq!(response["ticket"]["id"], "TICK-1");
1034        assert_eq!(response["ticket"]["flow"], "default");
1035    }
1036
1037    #[test]
1038    fn idless_tickets_get_monotonic_generated_ids() {
1039        let (root, store) = world();
1040        std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1041        std::fs::write(
1042            root.path().join(".agents/sloop/tickets/fix.md"),
1043            ticket("", "# A\n"),
1044        )
1045        .unwrap();
1046        std::fs::write(
1047            root.path().join(".agents/sloop/tickets/nested/fix.md"),
1048            ticket("", "# B\n"),
1049        )
1050        .unwrap();
1051
1052        let first = handle(
1053            root.path(),
1054            &store,
1055            &post(".agents/sloop/tickets/fix.md", PostActivation::Manual),
1056            2_000,
1057            "TICK",
1058            None,
1059            &flows(),
1060            "default",
1061        )
1062        .unwrap();
1063        let second = handle(
1064            root.path(),
1065            &store,
1066            &post(
1067                ".agents/sloop/tickets/nested/fix.md",
1068                PostActivation::Manual,
1069            ),
1070            2_100,
1071            "TICK",
1072            None,
1073            &flows(),
1074            "default",
1075        )
1076        .unwrap();
1077        assert_eq!(first["ticket"]["id"], "TICK-1");
1078        assert_eq!(second["ticket"]["id"], "TICK-2");
1079    }
1080
1081    #[test]
1082    fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1083        let (root, store) = world();
1084        let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1085        let explicit_content = ticket(
1086            "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1087            "# Explicit\n",
1088        );
1089        std::fs::write(&explicit, &explicit_content).unwrap();
1090        handle(
1091            root.path(),
1092            &store,
1093            &post(".agents/sloop/tickets/explicit.md", PostActivation::Manual),
1094            2_000,
1095            "WORK",
1096            None,
1097            &flows(),
1098            "default",
1099        )
1100        .unwrap();
1101        assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1102
1103        std::fs::write(
1104            root.path().join(".agents/sloop/tickets/unrelated.md"),
1105            ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1106        )
1107        .unwrap();
1108        handle(
1109            root.path(),
1110            &store,
1111            &post(".agents/sloop/tickets/unrelated.md", PostActivation::Manual),
1112            2_100,
1113            "WORK",
1114            None,
1115            &flows(),
1116            "default",
1117        )
1118        .unwrap();
1119
1120        std::fs::write(
1121            root.path().join(".agents/sloop/tickets/generated.md"),
1122            ticket("", "# Generated\n"),
1123        )
1124        .unwrap();
1125        let generated = handle(
1126            root.path(),
1127            &store,
1128            &post(".agents/sloop/tickets/generated.md", PostActivation::Manual),
1129            2_200,
1130            "WORK",
1131            None,
1132            &flows(),
1133            "default",
1134        )
1135        .unwrap();
1136        assert_eq!(generated["ticket"]["id"], "WORK-10");
1137    }
1138
1139    #[test]
1140    fn paths_escaping_the_repository_are_rejected() {
1141        let (root, store) = world();
1142        let args = post("../outside.md", PostActivation::Manual);
1143
1144        assert!(matches!(
1145            handle(
1146                root.path(),
1147                &store,
1148                &args,
1149                2_000,
1150                "TICK",
1151                None,
1152                &flows(),
1153                "default"
1154            ),
1155            Err(PostError::OutsideRepository(_))
1156        ));
1157    }
1158
1159    #[test]
1160    fn paths_outside_the_ticket_directory_are_rejected() {
1161        let (root, store) = world();
1162        std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1163
1164        assert!(matches!(
1165            handle(
1166                root.path(),
1167                &store,
1168                &post("elsewhere.md", PostActivation::Manual),
1169                2_000,
1170                "TICK",
1171                None,
1172                &flows(),
1173                "default",
1174            ),
1175            Err(PostError::OutsideTicketDirectory { .. })
1176        ));
1177    }
1178}