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#[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
246fn 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
333fn 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 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}