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
252/// Validates a ticket file, reporting *every* independent problem at once so
253/// authoring a ticket does not turn into one round-trip per mistake.
254///
255/// The split between short-circuiting and accumulating is deliberate. A file
256/// whose frontmatter cannot be read at all — no block, unterminated, YAML
257/// that does not parse, a block that is not a mapping — fails fast: no field
258/// can be read out of it, so every other check would either be unanswerable
259/// or degenerate into "everything is missing". Once a mapping is in hand,
260/// each field and the body are independent, and the caller deserves the full
261/// list. Checks that need the store (unknown blockers, dependency cycles,
262/// project/flow/target resolution) stay in `handle`: they are registration
263/// problems rather than problems with the file, and they carry their own
264/// error codes.
265pub(crate) fn parse_ticket_frontmatter(
266    content: &str,
267    path: &str,
268) -> Result<frontmatter::Frontmatter, PostError> {
269    let (stamped, field_errors) =
270        frontmatter::parse_collecting(content).map_err(|error| PostError::InvalidTicket {
271            path: path.to_owned(),
272            error,
273        })?;
274
275    // A field that failed to parse is already reported by its own problem;
276    // adding "missing" on top of "wrong type" would only muddy the list.
277    let name_is_reported = field_errors
278        .iter()
279        .any(|error| matches!(error, FrontmatterError::InvalidFieldType { key } if key == "name"));
280    let blocked_by_is_reported = field_errors
281        .iter()
282        .any(|error| matches!(error, FrontmatterError::InvalidBlockedBy));
283
284    let mut problems = Vec::new();
285    if !name_is_reported && stamped.name.trim().is_empty() {
286        problems.push(TicketProblem::MissingName);
287    }
288    if !blocked_by_is_reported && !stamped.has_blocked_by() {
289        problems.push(TicketProblem::MissingBlockedBy);
290    }
291    if frontmatter::body(content)
292        .expect("frontmatter was already parsed")
293        .trim()
294        .is_empty()
295    {
296        problems.push(TicketProblem::EmptyBody);
297    }
298    problems.extend(field_errors.into_iter().map(TicketProblem::from));
299
300    if problems.is_empty() {
301        Ok(stamped)
302    } else {
303        Err(PostError::InvalidTicketFields {
304            path: path.to_owned(),
305            problems,
306        })
307    }
308}
309
310/// Reuses an existing queued activation of the same kind so reposting cannot
311/// enqueue duplicate work. A timed repost moves the queued activation to the
312/// newly requested instant instead of keeping the stale one.
313fn queue_activation(
314    store: &Store,
315    ticket_id: &str,
316    kind: ActivationKind,
317    eligible_at_ms: Option<i64>,
318    now_ms: i64,
319) -> Result<Value, PostError> {
320    let id = match store.queued_ticket_activation(ticket_id, kind)? {
321        Some(id) => {
322            if let Some(eligible_at_ms) = eligible_at_ms {
323                store.reschedule_activation(&id, eligible_at_ms, now_ms)?;
324            }
325            id
326        }
327        None => {
328            let id = format!("A{}", store.next_activation_ordinal()?);
329            store.insert_activation(
330                &NewActivation {
331                    id: &id,
332                    kind,
333                    ticket_id: Some(ticket_id),
334                    project_id: None,
335                    eligible_at_ms,
336                    interval_ms: None,
337                },
338                now_ms,
339            )?;
340            id
341        }
342    };
343    let mut activation = json!({
344        "id": id,
345        "kind": kind.as_str(),
346        "state": "queued",
347        "ticket": ticket_id,
348    });
349    if let Some(eligible_at_ms) = eligible_at_ms {
350        activation["eligible_at_ms"] = json!(eligible_at_ms);
351    }
352    Ok(activation)
353}
354
355fn allocate_ticket_id(store: &Store, prefix: &str) -> Result<String, PostError> {
356    let ids = store.ticket_ids()?;
357    next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
358}
359
360/// Resolves the request path against the repository root and requires the
361/// result to stay inside the committed Sloop ticket directory.
362fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
363    let path = Path::new(file);
364    let joined = if path.is_absolute() {
365        path.to_path_buf()
366    } else {
367        root.join(path)
368    };
369
370    let mut normalized = PathBuf::new();
371    for component in joined.components() {
372        match component {
373            Component::CurDir => {}
374            Component::ParentDir => {
375                if !normalized.pop() {
376                    return Err(PostError::OutsideRepository(file.to_owned()));
377                }
378            }
379            component => normalized.push(component),
380        }
381    }
382    let relative = normalized
383        .strip_prefix(root)
384        .map(Path::to_path_buf)
385        .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
386    if !relative.starts_with(ticket_dir) {
387        return Err(PostError::OutsideTicketDirectory {
388            path: file.to_owned(),
389            directory: ticket_dir.to_path_buf(),
390        });
391    }
392    Ok(relative)
393}
394
395/// A single problem with a ticket file, phrased without the file path so
396/// several can be listed under one path heading.
397#[derive(Debug)]
398pub enum TicketProblem {
399    Frontmatter(FrontmatterError),
400    MissingName,
401    MissingBlockedBy,
402    InvalidBlockedBy,
403    EmptyBody,
404}
405
406impl From<FrontmatterError> for TicketProblem {
407    fn from(error: FrontmatterError) -> Self {
408        match error {
409            FrontmatterError::InvalidBlockedBy => Self::InvalidBlockedBy,
410            error => Self::Frontmatter(error),
411        }
412    }
413}
414
415impl fmt::Display for TicketProblem {
416    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
417        match self {
418            Self::Frontmatter(error) => error.fmt(formatter),
419            Self::MissingName => {
420                formatter.write_str("missing or empty `name`; add `name: Your ticket title`")
421            }
422            Self::MissingBlockedBy => formatter.write_str(
423                "missing `blocked_by`; add `blocked_by: []` if there are no dependencies",
424            ),
425            Self::InvalidBlockedBy => formatter.write_str(
426                "invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs",
427            ),
428            Self::EmptyBody => {
429                formatter.write_str("empty `body`; add a ticket description after the frontmatter")
430            }
431        }
432    }
433}
434
435#[derive(Debug)]
436pub enum PostError {
437    TicketFileNotFound(String),
438    OutsideRepository(String),
439    OutsideTicketDirectory {
440        path: String,
441        directory: PathBuf,
442    },
443    InvalidTicket {
444        path: String,
445        error: FrontmatterError,
446    },
447    /// One or more independent problems with the ticket file itself,
448    /// reported together. Never empty.
449    InvalidTicketFields {
450        path: String,
451        problems: Vec<TicketProblem>,
452    },
453    InvalidWorktreeStem {
454        path: String,
455        reason: String,
456    },
457    UnknownBlockedBy {
458        ticket: String,
459        blocker: String,
460    },
461    DependencyCycle(Vec<String>),
462    UnknownProject(String),
463    UnknownTarget(String),
464    MissingTargetValue {
465        target: String,
466        message: String,
467    },
468    ProjectConflict {
469        path: String,
470        stamped: String,
471        requested: String,
472    },
473    FlowConflict {
474        path: String,
475        stamped: String,
476        requested: String,
477    },
478    UnknownFlow {
479        flow: String,
480        known: Vec<String>,
481    },
482    TicketIdTaken {
483        id: String,
484        file: String,
485    },
486    Io {
487        path: String,
488        source: io::Error,
489    },
490    Store(StoreError),
491    IdAllocation(IdError),
492}
493
494impl From<StoreError> for PostError {
495    fn from(error: StoreError) -> Self {
496        Self::Store(error)
497    }
498}
499
500impl fmt::Display for PostError {
501    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
502        match self {
503            Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
504            Self::OutsideRepository(path) => {
505                write!(formatter, "`{path}` is outside the repository")
506            }
507            Self::OutsideTicketDirectory { path, directory } => write!(
508                formatter,
509                "`{path}` is outside the {} directory",
510                directory.display()
511            ),
512            Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
513            // A lone problem keeps the original one-line `path: problem`
514            // shape; only a genuine list needs the heading and bullets.
515            Self::InvalidTicketFields { path, problems } => match problems.as_slice() {
516                [problem] => write!(formatter, "{path}: {problem}"),
517                problems => {
518                    write!(formatter, "{path}:")?;
519                    for problem in problems {
520                        write!(formatter, "\n  - {problem}")?;
521                    }
522                    Ok(())
523                }
524            },
525            Self::InvalidWorktreeStem { path, reason } => {
526                write!(formatter, "{path}: {reason}")
527            }
528            Self::UnknownBlockedBy { ticket, blocker } => write!(
529                formatter,
530                "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
531            ),
532            Self::DependencyCycle(chain) => write!(
533                formatter,
534                "field `blocked_by` creates a dependency cycle: {}",
535                chain.join(" -> ")
536            ),
537            Self::UnknownProject(project) => {
538                write!(formatter, "project `{project}` is not indexed")
539            }
540            Self::UnknownTarget(target) => {
541                write!(formatter, "agent target `{target}` is not configured")
542            }
543            Self::MissingTargetValue { target, message } => {
544                write!(formatter, "ticket using agent target `{target}` {message}")
545            }
546            Self::ProjectConflict {
547                path,
548                stamped,
549                requested,
550            } => write!(
551                formatter,
552                "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
553            ),
554            Self::FlowConflict {
555                path,
556                stamped,
557                requested,
558            } => write!(
559                formatter,
560                "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
561            ),
562            Self::UnknownFlow { flow, known } => write!(
563                formatter,
564                "flow `{flow}` is not defined; known flows: {}",
565                known.join(", ")
566            ),
567            Self::TicketIdTaken { id, file } => write!(
568                formatter,
569                "ticket ID `{id}` is already registered by `{file}`"
570            ),
571            Self::Io { path, source } => write!(formatter, "{path}: {source}"),
572            Self::Store(error) => error.fmt(formatter),
573            Self::IdAllocation(error) => error.fmt(formatter),
574        }
575    }
576}
577
578impl std::error::Error for PostError {}
579
580#[cfg(test)]
581mod tests {
582    use std::collections::BTreeMap;
583
584    use tempfile::tempdir;
585
586    use super::{PostError, handle as handle_with_directory};
587    use crate::config::{AgentConfig, AgentTarget};
588    use crate::flow::{Flow, Stage, StageKind, VerdictPolicy};
589    use crate::protocol::{PostActivation, PostArgs};
590    use crate::store::Store;
591
592    fn world() -> (tempfile::TempDir, Store) {
593        let root = tempdir().unwrap();
594        std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
595        let store = Store::open(&root.path().join("sloop.db"), 1_000).unwrap();
596        store
597            .upsert_local_project(
598                "default",
599                ".agents/sloop/projects/default.md",
600                "Default",
601                1_000,
602            )
603            .unwrap();
604        (root, store)
605    }
606
607    #[allow(clippy::too_many_arguments)]
608    fn handle(
609        root: &std::path::Path,
610        store: &Store,
611        args: &PostArgs,
612        now_ms: i64,
613        ticket_prefix: &str,
614        agent: Option<&AgentConfig>,
615        flows: &BTreeMap<String, Flow>,
616        default_flow: &str,
617    ) -> Result<serde_json::Value, PostError> {
618        handle_with_directory(
619            root,
620            std::path::Path::new(".agents/sloop/tickets"),
621            store,
622            args,
623            now_ms,
624            None,
625            ticket_prefix,
626            agent,
627            flows,
628            default_flow,
629        )
630    }
631
632    fn handle_at(
633        root: &std::path::Path,
634        store: &Store,
635        args: &PostArgs,
636        now_ms: i64,
637        at_eligible_ms: i64,
638    ) -> Result<serde_json::Value, PostError> {
639        handle_with_directory(
640            root,
641            std::path::Path::new(".agents/sloop/tickets"),
642            store,
643            args,
644            now_ms,
645            Some(at_eligible_ms),
646            "TICK",
647            None,
648            &flows(),
649            "default",
650        )
651    }
652
653    fn post(file: &str, activation: PostActivation) -> PostArgs {
654        PostArgs {
655            file: file.into(),
656            project: None,
657            flow: None,
658            activation,
659        }
660    }
661
662    fn flows() -> BTreeMap<String, Flow> {
663        BTreeMap::from([
664            (
665                "default".to_owned(),
666                Flow {
667                    name: "default".into(),
668                    stages: vec![Stage {
669                        name: "build".into(),
670                        kind: StageKind::Agent,
671                        verdict: VerdictPolicy::Commits,
672                        on_fail: None,
673                    }],
674                },
675            ),
676            (
677                "release".to_owned(),
678                Flow {
679                    name: "release".into(),
680                    stages: vec![Stage {
681                        name: "build".into(),
682                        kind: StageKind::Agent,
683                        verdict: VerdictPolicy::Commits,
684                        on_fail: None,
685                    }],
686                },
687            ),
688        ])
689    }
690
691    fn ticket(frontmatter: &str, body: &str) -> String {
692        format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
693    }
694
695    fn agent() -> AgentConfig {
696        AgentConfig {
697            default_target: "claude".into(),
698            targets: BTreeMap::from([
699                (
700                    "claude".into(),
701                    AgentTarget {
702                        cmd: vec!["claude".into(), "{prompt}".into()],
703                        model: None,
704                        effort: None,
705                    },
706                ),
707                (
708                    "codex".into(),
709                    AgentTarget {
710                        cmd: vec![
711                            "codex".into(),
712                            "{model}".into(),
713                            "{effort}".into(),
714                            "{prompt}".into(),
715                        ],
716                        model: None,
717                        effort: None,
718                    },
719                ),
720            ]),
721        }
722    }
723
724    #[test]
725    fn posting_twice_reuses_the_registration_and_activation() {
726        let (root, store) = world();
727        std::fs::write(
728            root.path().join(".agents/sloop/tickets/cooldown.md"),
729            ticket("", "# Cooldowns\n"),
730        )
731        .unwrap();
732        let args = post(".agents/sloop/tickets/cooldown.md", PostActivation::Auto);
733
734        let first = handle(
735            root.path(),
736            &store,
737            &args,
738            2_000,
739            "TICK",
740            None,
741            &flows(),
742            "default",
743        )
744        .unwrap();
745        let second = handle(
746            root.path(),
747            &store,
748            &args,
749            3_000,
750            "TICK",
751            None,
752            &flows(),
753            "default",
754        )
755        .unwrap();
756        assert_eq!(first["ticket"]["id"], second["ticket"]["id"]);
757        assert_eq!(first["activation"]["id"], second["activation"]["id"]);
758    }
759
760    #[test]
761    fn posting_at_queues_a_timed_activation_and_reposting_reschedules_it() {
762        let (root, store) = world();
763        std::fs::write(
764            root.path().join(".agents/sloop/tickets/timed.md"),
765            ticket("", "# Timed\n"),
766        )
767        .unwrap();
768        let args = post(
769            ".agents/sloop/tickets/timed.md",
770            PostActivation::At {
771                time: "03:00".into(),
772            },
773        );
774
775        let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
776        assert_eq!(first["ticket"]["state"], "ready");
777        assert_eq!(first["activation"]["kind"], "at");
778        assert_eq!(first["activation"]["eligible_at_ms"], 10_000);
779
780        let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
781        assert_eq!(second["activation"]["id"], first["activation"]["id"]);
782        assert_eq!(second["activation"]["eligible_at_ms"], 20_000);
783
784        let queued = store.queued_activations().unwrap();
785        assert_eq!(queued.len(), 1);
786        assert_eq!(queued[0].eligible_at_ms, Some(20_000));
787    }
788
789    #[test]
790    fn posting_snapshots_the_default_target_and_reposting_refreshes_execution_values() {
791        let (root, store) = world();
792        let path = root.path().join(".agents/sloop/tickets/work.md");
793        std::fs::write(&path, ticket("model: sonnet\neffort: medium\n", "# Work\n")).unwrap();
794        let args = post(".agents/sloop/tickets/work.md", PostActivation::Manual);
795        let agent = agent();
796
797        let first = handle(
798            root.path(),
799            &store,
800            &args,
801            2_000,
802            "TICK",
803            Some(&agent),
804            &flows(),
805            "default",
806        )
807        .unwrap();
808        assert_eq!(first["ticket"]["target"], "claude");
809
810        std::fs::write(
811            &path,
812            ticket(
813                "id: TICK-1\nproject: default\ntarget: codex\nmodel: o3\neffort: high\n",
814                "# Work\n",
815            ),
816        )
817        .unwrap();
818        let second = handle(
819            root.path(),
820            &store,
821            &args,
822            3_000,
823            "TICK",
824            Some(&agent),
825            &flows(),
826            "default",
827        )
828        .unwrap();
829        assert_eq!(second["ticket"]["id"], first["ticket"]["id"]);
830        assert_eq!(second["ticket"]["target"], "codex");
831        assert_eq!(second["ticket"]["model"], "o3");
832        assert_eq!(second["ticket"]["effort"], "high");
833    }
834
835    #[test]
836    fn unknown_targets_are_rejected_before_registration_or_activation() {
837        let (root, store) = world();
838        std::fs::write(
839            root.path().join(".agents/sloop/tickets/work.md"),
840            ticket("target: missing\n", "# Work\n"),
841        )
842        .unwrap();
843        let args = post(".agents/sloop/tickets/work.md", PostActivation::Auto);
844
845        assert!(matches!(
846            handle(root.path(), &store, &args, 2_000, "TICK", Some(&agent()), &flows(), "default"),
847            Err(PostError::UnknownTarget(target)) if target == "missing"
848        ));
849        assert!(store.ticket_ids().unwrap().is_empty());
850        assert!(store.queued_activations().unwrap().is_empty());
851    }
852
853    #[test]
854    fn selected_target_placeholders_require_ticket_values_before_registration() {
855        let (root, store) = world();
856        std::fs::write(
857            root.path().join(".agents/sloop/tickets/work.md"),
858            ticket("target: codex\neffort: high\n", "# Work\n"),
859        )
860        .unwrap();
861        let args = post(".agents/sloop/tickets/work.md", PostActivation::Manual);
862
863        let error = handle(
864            root.path(),
865            &store,
866            &args,
867            2_000,
868            "TICK",
869            Some(&agent()),
870            &flows(),
871            "default",
872        )
873        .unwrap_err()
874        .to_string();
875        assert!(error.contains("agent target `codex`"), "{error}");
876        assert!(error.contains("does not specify `model`"), "{error}");
877        assert!(store.ticket_ids().unwrap().is_empty());
878    }
879
880    #[test]
881    fn a_stamped_project_mismatching_the_request_is_a_conflict() {
882        let (root, store) = world();
883        std::fs::write(
884            root.path().join(".agents/sloop/tickets/t.md"),
885            ticket("id: T1\nproject: default\n", "# Work\n"),
886        )
887        .unwrap();
888        let args = PostArgs {
889            file: ".agents/sloop/tickets/t.md".into(),
890            project: Some("other".into()),
891            flow: None,
892            activation: PostActivation::Manual,
893        };
894
895        assert!(matches!(
896            handle(
897                root.path(),
898                &store,
899                &args,
900                2_000,
901                "TICK",
902                None,
903                &flows(),
904                "default"
905            ),
906            Err(PostError::ProjectConflict { .. })
907        ));
908    }
909
910    #[test]
911    fn an_unknown_project_is_rejected() {
912        let (root, store) = world();
913        std::fs::write(
914            root.path().join(".agents/sloop/tickets/t.md"),
915            ticket("", "# T\n"),
916        )
917        .unwrap();
918        let args = PostArgs {
919            file: ".agents/sloop/tickets/t.md".into(),
920            project: Some("missing".into()),
921            flow: None,
922            activation: PostActivation::Manual,
923        };
924
925        assert!(matches!(
926            handle(root.path(), &store, &args, 2_000, "TICK", None, &flows(), "default"),
927            Err(PostError::UnknownProject(project)) if project == "missing"
928        ));
929    }
930
931    #[test]
932    fn a_missing_flow_is_stamped_with_the_default() {
933        let (root, store) = world();
934        let path = root.path().join(".agents/sloop/tickets/t.md");
935        std::fs::write(&path, ticket("", "# T\n")).unwrap();
936        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
937
938        let response = handle(
939            root.path(),
940            &store,
941            &args,
942            2_000,
943            "TICK",
944            None,
945            &flows(),
946            "default",
947        )
948        .unwrap();
949
950        assert_eq!(response["ticket"]["flow"], "default");
951        assert!(
952            std::fs::read_to_string(&path)
953                .unwrap()
954                .contains("flow: default")
955        );
956    }
957
958    #[test]
959    fn an_explicit_flow_is_honored() {
960        let (root, store) = world();
961        std::fs::write(
962            root.path().join(".agents/sloop/tickets/t.md"),
963            ticket("flow: release\n", "# T\n"),
964        )
965        .unwrap();
966        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
967
968        let response = handle(
969            root.path(),
970            &store,
971            &args,
972            2_000,
973            "TICK",
974            None,
975            &flows(),
976            "default",
977        )
978        .unwrap();
979
980        assert_eq!(response["ticket"]["flow"], "release");
981    }
982
983    #[test]
984    fn a_stamped_flow_mismatching_the_request_is_a_conflict() {
985        let (root, store) = world();
986        std::fs::write(
987            root.path().join(".agents/sloop/tickets/t.md"),
988            ticket("flow: release\n", "# T\n"),
989        )
990        .unwrap();
991        let args = PostArgs {
992            file: ".agents/sloop/tickets/t.md".into(),
993            project: None,
994            flow: Some("default".into()),
995            activation: PostActivation::Manual,
996        };
997
998        assert!(matches!(
999            handle(
1000                root.path(),
1001                &store,
1002                &args,
1003                2_000,
1004                "TICK",
1005                None,
1006                &flows(),
1007                "default"
1008            ),
1009            Err(PostError::FlowConflict { .. })
1010        ));
1011    }
1012
1013    #[test]
1014    fn an_unknown_flow_is_rejected_and_names_known_flows() {
1015        let (root, store) = world();
1016        std::fs::write(
1017            root.path().join(".agents/sloop/tickets/t.md"),
1018            ticket("flow: bogus\n", "# T\n"),
1019        )
1020        .unwrap();
1021        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
1022
1023        let error = handle(
1024            root.path(),
1025            &store,
1026            &args,
1027            2_000,
1028            "TICK",
1029            None,
1030            &flows(),
1031            "default",
1032        )
1033        .unwrap_err()
1034        .to_string();
1035        assert!(error.contains("bogus"), "{error}");
1036        assert!(error.contains("default"), "{error}");
1037        assert!(error.contains("release"), "{error}");
1038        assert!(store.ticket_ids().unwrap().is_empty());
1039    }
1040
1041    #[test]
1042    fn reindex_recovers_the_flow_binding_from_frontmatter_into_a_fresh_store() {
1043        let (root, store) = world();
1044        std::fs::write(
1045            root.path().join(".agents/sloop/tickets/t.md"),
1046            ticket("", "# T\n"),
1047        )
1048        .unwrap();
1049        let args = post(".agents/sloop/tickets/t.md", PostActivation::Manual);
1050        handle(
1051            root.path(),
1052            &store,
1053            &args,
1054            2_000,
1055            "TICK",
1056            None,
1057            &flows(),
1058            "default",
1059        )
1060        .unwrap();
1061        drop(store);
1062
1063        // A fresh store with no rows of its own must recover the flow binding
1064        // purely from the committed frontmatter that the first post stamped.
1065        let fresh_store = Store::open(&root.path().join("fresh.db"), 3_000).unwrap();
1066        fresh_store
1067            .upsert_local_project(
1068                "default",
1069                ".agents/sloop/projects/default.md",
1070                "Default",
1071                3_000,
1072            )
1073            .unwrap();
1074        let response = handle(
1075            root.path(),
1076            &fresh_store,
1077            &args,
1078            3_000,
1079            "TICK",
1080            None,
1081            &flows(),
1082            "default",
1083        )
1084        .unwrap();
1085
1086        assert_eq!(response["ticket"]["id"], "TICK-1");
1087        assert_eq!(response["ticket"]["flow"], "default");
1088    }
1089
1090    #[test]
1091    fn idless_tickets_get_monotonic_generated_ids() {
1092        let (root, store) = world();
1093        std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1094        std::fs::write(
1095            root.path().join(".agents/sloop/tickets/fix.md"),
1096            ticket("", "# A\n"),
1097        )
1098        .unwrap();
1099        std::fs::write(
1100            root.path().join(".agents/sloop/tickets/nested/fix.md"),
1101            ticket("", "# B\n"),
1102        )
1103        .unwrap();
1104
1105        let first = handle(
1106            root.path(),
1107            &store,
1108            &post(".agents/sloop/tickets/fix.md", PostActivation::Manual),
1109            2_000,
1110            "TICK",
1111            None,
1112            &flows(),
1113            "default",
1114        )
1115        .unwrap();
1116        let second = handle(
1117            root.path(),
1118            &store,
1119            &post(
1120                ".agents/sloop/tickets/nested/fix.md",
1121                PostActivation::Manual,
1122            ),
1123            2_100,
1124            "TICK",
1125            None,
1126            &flows(),
1127            "default",
1128        )
1129        .unwrap();
1130        assert_eq!(first["ticket"]["id"], "TICK-1");
1131        assert_eq!(second["ticket"]["id"], "TICK-2");
1132    }
1133
1134    #[test]
1135    fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1136        let (root, store) = world();
1137        let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1138        let explicit_content = ticket(
1139            "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1140            "# Explicit\n",
1141        );
1142        std::fs::write(&explicit, &explicit_content).unwrap();
1143        handle(
1144            root.path(),
1145            &store,
1146            &post(".agents/sloop/tickets/explicit.md", PostActivation::Manual),
1147            2_000,
1148            "WORK",
1149            None,
1150            &flows(),
1151            "default",
1152        )
1153        .unwrap();
1154        assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1155
1156        std::fs::write(
1157            root.path().join(".agents/sloop/tickets/unrelated.md"),
1158            ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1159        )
1160        .unwrap();
1161        handle(
1162            root.path(),
1163            &store,
1164            &post(".agents/sloop/tickets/unrelated.md", PostActivation::Manual),
1165            2_100,
1166            "WORK",
1167            None,
1168            &flows(),
1169            "default",
1170        )
1171        .unwrap();
1172
1173        std::fs::write(
1174            root.path().join(".agents/sloop/tickets/generated.md"),
1175            ticket("", "# Generated\n"),
1176        )
1177        .unwrap();
1178        let generated = handle(
1179            root.path(),
1180            &store,
1181            &post(".agents/sloop/tickets/generated.md", PostActivation::Manual),
1182            2_200,
1183            "WORK",
1184            None,
1185            &flows(),
1186            "default",
1187        )
1188        .unwrap();
1189        assert_eq!(generated["ticket"]["id"], "WORK-10");
1190    }
1191
1192    #[test]
1193    fn paths_escaping_the_repository_are_rejected() {
1194        let (root, store) = world();
1195        let args = post("../outside.md", PostActivation::Manual);
1196
1197        assert!(matches!(
1198            handle(
1199                root.path(),
1200                &store,
1201                &args,
1202                2_000,
1203                "TICK",
1204                None,
1205                &flows(),
1206                "default"
1207            ),
1208            Err(PostError::OutsideRepository(_))
1209        ));
1210    }
1211
1212    #[test]
1213    fn paths_outside_the_ticket_directory_are_rejected() {
1214        let (root, store) = world();
1215        std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1216
1217        assert!(matches!(
1218            handle(
1219                root.path(),
1220                &store,
1221                &post("elsewhere.md", PostActivation::Manual),
1222                2_000,
1223                "TICK",
1224                None,
1225                &flows(),
1226                "default",
1227            ),
1228            Err(PostError::OutsideTicketDirectory { .. })
1229        ));
1230    }
1231}