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