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#[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(
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 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
310fn 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
360fn 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#[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 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 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 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}