1use std::collections::BTreeMap;
2use std::fmt;
3use std::fs;
4use std::fs::OpenOptions;
5use std::io::{self, Write};
6use std::path::{Component, Path, PathBuf};
7use std::sync::Mutex;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use async_trait::async_trait;
11use rusqlite::{Transaction, TransactionBehavior};
12use serde_json::{Value, json};
13
14use crate::config::{AgentConfig, expand_agent_cmd};
15use crate::db::StoreError;
16use crate::domain::ticket::TicketState;
17use crate::domain::trigger::TriggerKind;
18use crate::domain::work::{ExecutionHints, SourceVersion, TicketRef, WorkTicket, WorkTicketState};
19use crate::flow::Flow;
20use crate::frontmatter::{self, FrontmatterError};
21use crate::ids::{IdError, next_id};
22use crate::protocol::{PostArgs, PostTrigger};
23use crate::work_state::local::{self, LocalSqlite, LocalTicketWrite};
24use crate::work_state::trigger::{self, Duplicates, EnqueueRequest};
25use crate::work_state::{SourceError, WorkStateAuthor};
26
27#[derive(Clone, Copy)]
28struct TriggerRequest {
29 kind: TriggerKind,
30 eligible_at_ms: Option<i64>,
31}
32
33static POST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
34
35struct StagedWrite {
36 path: PathBuf,
37 target: PathBuf,
38 persisted: bool,
39}
40
41impl StagedWrite {
42 fn new(target: PathBuf, content: &str) -> io::Result<Self> {
43 let parent = target.parent().unwrap_or_else(|| Path::new("."));
44 let name = target
45 .file_name()
46 .and_then(|name| name.to_str())
47 .unwrap_or("ticket");
48 let (path, mut file) = loop {
49 let ordinal = POST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
50 let path = parent.join(format!(
51 ".{name}.sloop-post-{}-{ordinal}.tmp",
52 std::process::id()
53 ));
54 match OpenOptions::new().write(true).create_new(true).open(&path) {
55 Ok(file) => break (path, file),
56 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
57 Err(error) => return Err(error),
58 }
59 };
60 let staged = Self {
61 path,
62 target,
63 persisted: false,
64 };
65 if let Ok(metadata) = fs::metadata(&staged.target) {
66 fs::set_permissions(&staged.path, metadata.permissions())?;
67 }
68 file.write_all(content.as_bytes())?;
69 file.sync_all()?;
70 Ok(staged)
71 }
72
73 fn persist(mut self) -> io::Result<()> {
74 fs::rename(&self.path, &self.target)?;
75 self.persisted = true;
76 Ok(())
77 }
78}
79
80impl Drop for StagedWrite {
81 fn drop(&mut self) {
82 if !self.persisted {
83 let _ = fs::remove_file(&self.path);
84 }
85 }
86}
87
88struct MarkdownWorkStateAuthor<'a> {
95 root: &'a Path,
96 file_path: &'a str,
97 worktree: &'a str,
98 work_state: &'a LocalSqlite,
99 original_content: &'a str,
100 final_content: &'a str,
101 original_version: SourceVersion,
102 trigger: Option<TriggerRequest>,
103 now_ms: i64,
104 trigger_result: Mutex<Value>,
105}
106
107impl MarkdownWorkStateAuthor<'_> {
108 fn absolute_path(&self) -> PathBuf {
109 self.root.join(self.file_path)
110 }
111
112 fn ensure_source_version(&self, expected: &SourceVersion) -> Result<(), SourceError> {
113 let path = self.absolute_path();
114 let content = fs::read_to_string(&path).map_err(|error| SourceError::Corrupt {
115 message: format!("cannot read {}: {error}", path.display()),
116 })?;
117 let actual = source_version(&content);
118 if &actual != expected {
119 return Err(SourceError::Rejected {
120 message: format!(
121 "source version conflict for `{}`: expected {}, found {}",
122 self.file_path, expected.0, actual.0
123 ),
124 });
125 }
126 Ok(())
127 }
128
129 fn commit(
130 &self,
131 ticket: &WorkTicket,
132 update: bool,
133 expected: &SourceVersion,
134 ) -> Result<(), SourceError> {
135 let staged = (self.final_content != self.original_content)
136 .then(|| StagedWrite::new(self.absolute_path(), self.final_content))
137 .transpose()
138 .map_err(|error| SourceError::Corrupt {
139 message: format!("cannot stage {}: {error}", self.file_path),
140 })?;
141 self.ensure_source_version(expected)?;
142 let db = self.work_state.db();
143 let mut connection = db.lock();
144 let transaction = connection
145 .transaction_with_behavior(TransactionBehavior::Immediate)
146 .map_err(StoreError::from)
147 .map_err(source_store_error)?;
148 let write = LocalTicketWrite {
149 id: &ticket.id,
150 project_id: &ticket.project_id,
151 file_path: self.file_path,
152 name: &ticket.name,
153 blocked_by: &ticket.blocked_by,
154 worktree: self.worktree,
155 target: ticket.hints.target.as_deref(),
156 model: ticket.hints.model.as_deref(),
157 effort: ticket.hints.effort.as_deref(),
158 flow: ticket.hints.flow.as_deref().unwrap_or_default(),
159 state: ticket.state.to_ticket_state(),
160 body: &ticket.body,
161 content_hash: &ticket.version.0,
162 now_ms: self.now_ms,
163 };
164 if update {
165 local::tx::update_authored_ticket(&transaction, &write).map_err(source_store_error)?;
166 } else {
167 local::tx::insert_authored_ticket(&transaction, &write).map_err(source_store_error)?;
168 }
169 let trigger =
170 queue_trigger_transaction(&transaction, &ticket.id, self.trigger, self.now_ms)
171 .map_err(source_store_error)?;
172 self.ensure_source_version(expected)?;
173 transaction
174 .commit()
175 .map_err(StoreError::from)
176 .map_err(source_store_error)?;
177 drop(connection);
178
179 if let Some(staged) = staged {
180 staged.persist().map_err(|error| SourceError::Corrupt {
181 message: format!("cannot replace {}: {error}", self.file_path),
182 })?;
183 }
184 *self
185 .trigger_result
186 .lock()
187 .unwrap_or_else(|poisoned| poisoned.into_inner()) = trigger;
188 Ok(())
189 }
190
191 fn trigger_result(&self) -> Value {
192 self.trigger_result
193 .lock()
194 .unwrap_or_else(|poisoned| poisoned.into_inner())
195 .clone()
196 }
197}
198
199#[async_trait]
200impl WorkStateAuthor for MarkdownWorkStateAuthor<'_> {
201 async fn post(&self, ticket: &WorkTicket) -> Result<TicketRef, SourceError> {
202 self.commit(ticket, false, &self.original_version)?;
203 Ok(TicketRef {
204 id: ticket.id.clone(),
205 source: "local".into(),
206 source_ref: Some(self.file_path.into()),
207 })
208 }
209
210 async fn update(
211 &self,
212 ticket: &TicketRef,
213 content: &WorkTicket,
214 expected: &SourceVersion,
215 ) -> Result<SourceVersion, SourceError> {
216 if ticket.id != content.id || ticket.source_ref.as_deref() != Some(self.file_path) {
217 return Err(SourceError::Rejected {
218 message: format!("ticket reference conflict for `{}`", content.id),
219 });
220 }
221 self.commit(content, true, expected)?;
222 Ok(content.version.clone())
223 }
224}
225
226#[allow(clippy::too_many_arguments)]
237pub async fn handle(
238 root: &Path,
239 ticket_dir: &Path,
240 work_state: &LocalSqlite,
241 args: &PostArgs,
242 now_ms: i64,
243 at_eligible_ms: Option<i64>,
244 ticket_prefix: &str,
245 agent: Option<&AgentConfig>,
246 flows: &BTreeMap<String, Flow>,
247 default_flow: &str,
248) -> Result<Value, PostError> {
249 let initial_state = match args.trigger {
250 PostTrigger::Hold => TicketState::Held,
251 _ => TicketState::Ready,
252 };
253 let relative = repository_relative(root, ticket_dir, &args.file)?;
254 let relative_str = relative.to_string_lossy().into_owned();
255 let absolute = root.join(&relative);
256 let content = fs::read_to_string(&absolute).map_err(|source| {
257 if source.kind() == io::ErrorKind::NotFound {
258 PostError::TicketFileNotFound(relative_str.clone())
259 } else {
260 PostError::Io {
261 path: relative_str.clone(),
262 source,
263 }
264 }
265 })?;
266 let stamped = parse_ticket_frontmatter(&content, &relative_str)?;
267
268 let project = match (stamped.project.as_deref(), args.project.as_deref()) {
269 (Some(stamped), Some(requested)) if stamped != requested => {
270 return Err(PostError::ProjectConflict {
271 path: relative_str,
272 stamped: stamped.into(),
273 requested: requested.into(),
274 });
275 }
276 (Some(stamped), _) => stamped.to_owned(),
277 (None, Some(requested)) => requested.to_owned(),
278 (None, None) => "default".to_owned(),
279 };
280 if !work_state.project_exists(&project)? {
281 return Err(PostError::UnknownProject(project));
282 }
283
284 let flow_name = match (stamped.flow.as_deref(), args.flow.as_deref()) {
285 (Some(stamped), Some(requested)) if stamped != requested => {
286 return Err(PostError::FlowConflict {
287 path: relative_str,
288 stamped: stamped.into(),
289 requested: requested.into(),
290 });
291 }
292 (Some(stamped), _) => stamped.to_owned(),
293 (None, Some(requested)) => requested.to_owned(),
294 (None, None) => default_flow.to_owned(),
295 };
296 if !flows.contains_key(&flow_name) {
297 let mut known: Vec<&str> = flows.keys().map(String::as_str).collect();
298 known.sort_unstable();
299 return Err(PostError::UnknownFlow {
300 flow: flow_name,
301 known: known.into_iter().map(str::to_owned).collect(),
302 });
303 }
304
305 let target = match stamped.target.as_deref() {
306 Some(target) if agent.is_some_and(|agent| agent.targets.contains_key(target)) => {
307 Some(target.to_owned())
308 }
309 Some(target) => return Err(PostError::UnknownTarget(target.to_owned())),
310 None => agent.map(|agent| agent.default_target.clone()),
311 };
312 if let (Some(agent), Some(target)) = (agent, target.as_deref()) {
313 let command = agent
314 .targets
315 .get(target)
316 .expect("configured default target was validated");
317 expand_agent_cmd(
318 command,
319 stamped.model.as_deref(),
320 stamped.effort.as_deref(),
321 "",
322 )
323 .map_err(|message| PostError::MissingTargetValue {
324 target: target.to_owned(),
325 message,
326 })?;
327 }
328
329 let (ticket_id, existing) = match stamped.id.as_deref() {
330 Some(id) => {
331 if let Some(existing) = work_state.ticket(id)? {
332 if existing.file_path.as_deref() != Some(relative_str.as_str()) {
333 return Err(PostError::TicketIdTaken {
334 id: id.to_owned(),
335 file: existing.file_path.unwrap_or_default(),
336 });
337 }
338 if existing.project_id != project {
339 return Err(PostError::ProjectConflict {
340 path: relative_str,
341 stamped: project,
342 requested: existing.project_id,
343 });
344 }
345 (id.to_owned(), Some(existing))
346 } else {
347 (id.to_owned(), None)
348 }
349 }
350 None => match work_state.ticket_by_file(&relative_str)? {
351 Some(existing) => {
352 if existing.project_id != project {
353 return Err(PostError::ProjectConflict {
354 path: relative_str,
355 stamped: project,
356 requested: existing.project_id,
357 });
358 }
359 (existing.id.clone(), Some(existing))
360 }
361 None => (allocate_ticket_id(work_state, ticket_prefix)?, None),
362 },
363 };
364 for blocker in &stamped.blocked_by {
365 if blocker != &ticket_id && work_state.ticket(blocker)?.is_none() {
366 return Err(PostError::UnknownBlockedBy {
367 ticket: ticket_id.clone(),
368 blocker: blocker.clone(),
369 });
370 }
371 }
372 let mut dependencies = work_state.ticket_dependencies()?;
373 dependencies.insert(ticket_id.clone(), stamped.blocked_by.clone());
374 if let Some(chain) = crate::domain::graph::find_cycle(&dependencies) {
375 return Err(PostError::DependencyCycle(chain));
376 }
377
378 let worktree = match stamped.worktree.clone() {
379 Some(worktree) => worktree,
380 None => {
381 let stem = Path::new(&relative_str)
382 .file_stem()
383 .and_then(|stem| stem.to_str());
384 crate::ids::default_worktree(stem, &ticket_id).map_err(|reason| {
385 PostError::InvalidWorktreeStem {
386 path: relative_str.clone(),
387 reason,
388 }
389 })?
390 }
391 };
392 let final_content = frontmatter::stamp(&content, &ticket_id, &project, &worktree, &flow_name)
393 .map_err(|error| PostError::InvalidTicket {
394 path: relative_str.clone(),
395 error,
396 })?
397 .unwrap_or_else(|| content.clone());
398 let terminal_state = existing
407 .as_ref()
408 .map(|ticket| ticket.state.clone())
409 .filter(|state| matches!(state.as_str(), "merged" | "failed" | "needs_review"));
410 let trigger_request = if terminal_state.is_some() {
411 None
412 } else {
413 match &args.trigger {
414 PostTrigger::Manual | PostTrigger::Hold => None,
415 PostTrigger::Auto => Some(TriggerRequest {
416 kind: TriggerKind::Auto,
417 eligible_at_ms: None,
418 }),
419 PostTrigger::At { .. } => Some(TriggerRequest {
420 kind: TriggerKind::At,
421 eligible_at_ms: Some(
422 at_eligible_ms.expect("the dispatcher computes eligibility for at triggers"),
423 ),
424 }),
425 }
426 };
427 let work_ticket = WorkTicket {
428 id: ticket_id.clone(),
429 project_id: project.clone(),
430 name: stamped.name.clone(),
431 body: frontmatter::body(&content)
432 .expect("validated frontmatter has a body")
433 .to_owned(),
434 state: WorkTicketState::from_ticket_state(
435 initial_state,
436 false,
437 String::new(),
438 crate::domain::work::OwnerId(String::new()),
439 ),
440 blocked_by: stamped.blocked_by.clone(),
441 attempts: existing.as_ref().map_or(0, |ticket| ticket.attempts as u32),
442 hints: ExecutionHints {
443 worktree: Some(worktree.clone()),
444 trigger_id: None,
445 target,
446 model: stamped.model.clone(),
447 effort: stamped.effort.clone(),
448 flow: Some(flow_name.clone()),
449 },
450 version: source_version(&final_content),
451 };
452 let author = MarkdownWorkStateAuthor {
453 root,
454 file_path: &relative_str,
455 worktree: &worktree,
456 work_state,
457 original_content: &content,
458 final_content: &final_content,
459 original_version: source_version(&content),
460 trigger: trigger_request,
461 now_ms,
462 trigger_result: Mutex::new(Value::Null),
463 };
464 let ticket_ref = TicketRef {
465 id: ticket_id,
466 source: "local".into(),
467 source_ref: Some(relative_str.clone()),
468 };
469 let created = existing.is_none();
470 if created {
471 author.post(&work_ticket).await?;
472 } else {
473 author
474 .update(&ticket_ref, &work_ticket, &author.original_version)
475 .await?;
476 }
477 let trigger = author.trigger_result();
478 let ticket = work_state
479 .ticket(&work_ticket.id)?
480 .expect("registered ticket still exists");
481
482 Ok(json!({
483 "ticket": {
484 "id": ticket.id,
485 "project": project,
486 "file": relative_str,
487 "state": ticket.state,
488 "name": ticket.name,
489 "blocked_by": ticket.blocked_by,
490 "worktree": ticket.worktree,
491 "target": ticket.target,
492 "model": ticket.model,
493 "effort": ticket.effort,
494 "flow": ticket.flow,
495 },
496 "created": created,
497 "trigger": trigger,
498 "trigger_suppressed": terminal_state.map(|state| json!({
502 "reason": "terminal_ticket",
503 "state": state,
504 })),
505 }))
506}
507
508pub(crate) fn parse_ticket_frontmatter(
522 content: &str,
523 path: &str,
524) -> Result<frontmatter::Frontmatter, PostError> {
525 let (stamped, field_errors) =
526 frontmatter::parse_collecting(content).map_err(|error| PostError::InvalidTicket {
527 path: path.to_owned(),
528 error,
529 })?;
530
531 let name_is_reported = field_errors
534 .iter()
535 .any(|error| matches!(error, FrontmatterError::InvalidFieldType { key } if key == "name"));
536 let blocked_by_is_reported = field_errors
537 .iter()
538 .any(|error| matches!(error, FrontmatterError::InvalidBlockedBy));
539
540 let mut problems = Vec::new();
541 if !name_is_reported && stamped.name.trim().is_empty() {
542 problems.push(TicketProblem::MissingName);
543 }
544 if !blocked_by_is_reported && !stamped.has_blocked_by() {
545 problems.push(TicketProblem::MissingBlockedBy);
546 }
547 if frontmatter::body(content)
548 .expect("frontmatter was already parsed")
549 .trim()
550 .is_empty()
551 {
552 problems.push(TicketProblem::EmptyBody);
553 }
554 problems.extend(field_errors.into_iter().map(TicketProblem::from));
555
556 if problems.is_empty() {
557 Ok(stamped)
558 } else {
559 Err(PostError::InvalidTicketFields {
560 path: path.to_owned(),
561 problems,
562 })
563 }
564}
565
566fn queue_trigger_transaction(
575 transaction: &Transaction<'_>,
576 ticket_id: &str,
577 request: Option<TriggerRequest>,
578 now_ms: i64,
579) -> Result<Value, StoreError> {
580 let Some(request) = request else {
581 return Ok(Value::Null);
582 };
583 let id = trigger::enqueue(
584 transaction,
585 &EnqueueRequest {
586 kind: request.kind,
587 ticket_id: Some(ticket_id),
588 project_id: None,
589 eligible_at_ms: request.eligible_at_ms,
590 interval_ms: None,
591 filters: &[],
592 duplicates: Duplicates::Reuse,
593 },
594 now_ms,
595 )?
596 .id;
597 let mut trigger = json!({
598 "id": id,
599 "kind": request.kind.as_str(),
600 "state": "queued",
601 "ticket": ticket_id,
602 });
603 if let Some(eligible_at_ms) = request.eligible_at_ms {
604 trigger["eligible_at_ms"] = json!(eligible_at_ms);
605 }
606 Ok(trigger)
607}
608
609fn source_store_error(error: StoreError) -> SourceError {
610 if error.is_disk_full() {
611 SourceError::Unavailable { retry_after: None }
612 } else {
613 SourceError::Corrupt {
614 message: error.to_string(),
615 }
616 }
617}
618
619fn source_version(content: &str) -> SourceVersion {
620 let mut hash = 0xcbf29ce484222325_u64;
621 for byte in content.as_bytes() {
622 hash ^= u64::from(*byte);
623 hash = hash.wrapping_mul(0x100000001b3);
624 }
625 SourceVersion(format!("{hash:016x}"))
626}
627
628fn allocate_ticket_id(work_state: &LocalSqlite, prefix: &str) -> Result<String, PostError> {
629 let ids = work_state.ticket_ids()?;
630 next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
631}
632
633fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
636 let path = Path::new(file);
637 let joined = if path.is_absolute() {
638 path.to_path_buf()
639 } else {
640 root.join(path)
641 };
642
643 let mut normalized = PathBuf::new();
644 for component in joined.components() {
645 match component {
646 Component::CurDir => {}
647 Component::ParentDir => {
648 if !normalized.pop() {
649 return Err(PostError::OutsideRepository(file.to_owned()));
650 }
651 }
652 component => normalized.push(component),
653 }
654 }
655 let relative = resolve_symlinks(&normalized)
659 .strip_prefix(resolve_symlinks(root))
660 .map(Path::to_path_buf)
661 .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
662 if !relative.starts_with(ticket_dir) {
663 return Err(PostError::OutsideTicketDirectory {
664 path: file.to_owned(),
665 directory: ticket_dir.to_path_buf(),
666 });
667 }
668 Ok(relative)
669}
670
671fn resolve_symlinks(path: &Path) -> PathBuf {
683 let mut unresolved = Vec::new();
684 let mut prefix = path;
685 loop {
686 if let Ok(resolved) = prefix.canonicalize() {
687 return unresolved
688 .iter()
689 .rev()
690 .fold(resolved, |base, component| base.join(component));
691 }
692 match (prefix.parent(), prefix.file_name()) {
693 (Some(parent), Some(name)) => {
694 unresolved.push(name);
695 prefix = parent;
696 }
697 _ => return path.to_path_buf(),
698 }
699 }
700}
701
702#[derive(Debug)]
705pub enum TicketProblem {
706 Frontmatter(FrontmatterError),
707 MissingName,
708 MissingBlockedBy,
709 InvalidBlockedBy,
710 EmptyBody,
711}
712
713impl From<FrontmatterError> for TicketProblem {
714 fn from(error: FrontmatterError) -> Self {
715 match error {
716 FrontmatterError::InvalidBlockedBy => Self::InvalidBlockedBy,
717 error => Self::Frontmatter(error),
718 }
719 }
720}
721
722impl fmt::Display for TicketProblem {
723 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
724 match self {
725 Self::Frontmatter(error) => error.fmt(formatter),
726 Self::MissingName => {
727 formatter.write_str("missing or empty `name`; add `name: Your ticket title`")
728 }
729 Self::MissingBlockedBy => formatter.write_str(
730 "missing `blocked_by`; add `blocked_by: []` if there are no dependencies",
731 ),
732 Self::InvalidBlockedBy => formatter.write_str(
733 "invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs",
734 ),
735 Self::EmptyBody => {
736 formatter.write_str("empty `body`; add a ticket description after the frontmatter")
737 }
738 }
739 }
740}
741
742#[derive(Debug)]
743pub enum PostError {
744 TicketFileNotFound(String),
745 OutsideRepository(String),
746 OutsideTicketDirectory {
747 path: String,
748 directory: PathBuf,
749 },
750 InvalidTicket {
751 path: String,
752 error: FrontmatterError,
753 },
754 InvalidTicketFields {
757 path: String,
758 problems: Vec<TicketProblem>,
759 },
760 InvalidWorktreeStem {
761 path: String,
762 reason: String,
763 },
764 UnknownBlockedBy {
765 ticket: String,
766 blocker: String,
767 },
768 DependencyCycle(Vec<String>),
769 UnknownProject(String),
770 UnknownTarget(String),
771 MissingTargetValue {
772 target: String,
773 message: String,
774 },
775 ProjectConflict {
776 path: String,
777 stamped: String,
778 requested: String,
779 },
780 FlowConflict {
781 path: String,
782 stamped: String,
783 requested: String,
784 },
785 UnknownFlow {
786 flow: String,
787 known: Vec<String>,
788 },
789 TicketIdTaken {
790 id: String,
791 file: String,
792 },
793 Io {
794 path: String,
795 source: io::Error,
796 },
797 Source(SourceError),
798 Store(StoreError),
799 IdAllocation(IdError),
800}
801
802impl From<SourceError> for PostError {
803 fn from(error: SourceError) -> Self {
804 Self::Source(error)
805 }
806}
807
808impl From<StoreError> for PostError {
809 fn from(error: StoreError) -> Self {
810 Self::Store(error)
811 }
812}
813
814impl fmt::Display for PostError {
815 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
816 match self {
817 Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
818 Self::OutsideRepository(path) => {
819 write!(formatter, "`{path}` is outside the repository")
820 }
821 Self::OutsideTicketDirectory { path, directory } => write!(
822 formatter,
823 "`{path}` is outside the {} directory",
824 directory.display()
825 ),
826 Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
827 Self::InvalidTicketFields { path, problems } => match problems.as_slice() {
830 [problem] => write!(formatter, "{path}: {problem}"),
831 problems => {
832 write!(formatter, "{path}:")?;
833 for problem in problems {
834 write!(formatter, "\n - {problem}")?;
835 }
836 Ok(())
837 }
838 },
839 Self::InvalidWorktreeStem { path, reason } => {
840 write!(formatter, "{path}: {reason}")
841 }
842 Self::UnknownBlockedBy { ticket, blocker } => write!(
843 formatter,
844 "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
845 ),
846 Self::DependencyCycle(chain) => write!(
847 formatter,
848 "field `blocked_by` creates a dependency cycle: {}",
849 chain.join(" -> ")
850 ),
851 Self::UnknownProject(project) => {
852 write!(formatter, "project `{project}` is not indexed")
853 }
854 Self::UnknownTarget(target) => {
855 write!(formatter, "agent target `{target}` is not configured")
856 }
857 Self::MissingTargetValue { target, message } => {
858 write!(formatter, "ticket using agent target `{target}` {message}")
859 }
860 Self::ProjectConflict {
861 path,
862 stamped,
863 requested,
864 } => write!(
865 formatter,
866 "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
867 ),
868 Self::FlowConflict {
869 path,
870 stamped,
871 requested,
872 } => write!(
873 formatter,
874 "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
875 ),
876 Self::UnknownFlow { flow, known } => write!(
877 formatter,
878 "flow `{flow}` is not defined; known flows: {}",
879 known.join(", ")
880 ),
881 Self::TicketIdTaken { id, file } => write!(
882 formatter,
883 "ticket ID `{id}` is already registered by `{file}`"
884 ),
885 Self::Io { path, source } => write!(formatter, "{path}: {source}"),
886 Self::Source(error) => error.fmt(formatter),
887 Self::Store(error) => error.fmt(formatter),
888 Self::IdAllocation(error) => error.fmt(formatter),
889 }
890 }
891}
892
893impl std::error::Error for PostError {}
894
895#[cfg(test)]
896mod tests {
897 use std::collections::BTreeMap;
898
899 use tempfile::tempdir;
900
901 use super::{
902 MarkdownWorkStateAuthor, PostError, handle as handle_with_directory, source_version,
903 };
904 use crate::config::{AgentConfig, AgentTarget};
905 use crate::db::Db;
906 use crate::domain::work::{ExecutionHints, TicketRef, WorkTicket, WorkTicketState};
907 use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage};
908 use crate::protocol::{PostArgs, PostTrigger};
909 use crate::work_state::local::LocalSqlite;
910 use crate::work_state::{SourceError, WorkStateAuthor};
911
912 fn world() -> (tempfile::TempDir, LocalSqlite) {
913 let root = tempdir().unwrap();
914 std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
915 let store = LocalSqlite::from_db(Db::open(&root.path().join("sloop.db"), 1_000).unwrap());
916 store
917 .upsert_local_project(
918 "default",
919 ".agents/sloop/projects/default.md",
920 "Default",
921 1_000,
922 )
923 .unwrap();
924 (root, store)
925 }
926
927 #[allow(clippy::too_many_arguments)]
928 fn handle(
929 root: &std::path::Path,
930 store: &LocalSqlite,
931 args: &PostArgs,
932 now_ms: i64,
933 ticket_prefix: &str,
934 agent: Option<&AgentConfig>,
935 flows: &BTreeMap<String, Flow>,
936 default_flow: &str,
937 ) -> Result<serde_json::Value, PostError> {
938 tokio::runtime::Runtime::new()
939 .unwrap()
940 .block_on(handle_with_directory(
941 root,
942 std::path::Path::new(".agents/sloop/tickets"),
943 store,
944 args,
945 now_ms,
946 None,
947 ticket_prefix,
948 agent,
949 flows,
950 default_flow,
951 ))
952 }
953
954 fn handle_at(
955 root: &std::path::Path,
956 store: &LocalSqlite,
957 args: &PostArgs,
958 now_ms: i64,
959 at_eligible_ms: i64,
960 ) -> Result<serde_json::Value, PostError> {
961 tokio::runtime::Runtime::new()
962 .unwrap()
963 .block_on(handle_with_directory(
964 root,
965 std::path::Path::new(".agents/sloop/tickets"),
966 store,
967 args,
968 now_ms,
969 Some(at_eligible_ms),
970 "TICK",
971 None,
972 &flows(),
973 "default",
974 ))
975 }
976
977 fn post(file: &str, trigger: PostTrigger) -> PostArgs {
978 PostArgs {
979 file: file.into(),
980 project: None,
981 flow: None,
982 trigger,
983 }
984 }
985
986 fn flows() -> BTreeMap<String, Flow> {
987 BTreeMap::from([
988 (
989 "default".to_owned(),
990 Flow {
991 name: "default".into(),
992 stages: vec![Stage {
993 name: "build".into(),
994 action: Actor::Agent,
995 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
996 fail_action: FailAction::Halt,
997 ff_only: false,
998 }],
999 },
1000 ),
1001 (
1002 "release".to_owned(),
1003 Flow {
1004 name: "release".into(),
1005 stages: vec![Stage {
1006 name: "build".into(),
1007 action: Actor::Agent,
1008 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
1009 fail_action: FailAction::Halt,
1010 ff_only: false,
1011 }],
1012 },
1013 ),
1014 ])
1015 }
1016
1017 fn ticket(frontmatter: &str, body: &str) -> String {
1018 format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
1019 }
1020
1021 fn agent() -> AgentConfig {
1022 AgentConfig {
1023 default_target: "claude".into(),
1024 targets: BTreeMap::from([
1025 (
1026 "claude".into(),
1027 AgentTarget {
1028 cmd: vec!["claude".into(), "{prompt}".into()],
1029 model: None,
1030 effort: None,
1031 },
1032 ),
1033 (
1034 "codex".into(),
1035 AgentTarget {
1036 cmd: vec![
1037 "codex".into(),
1038 "{model}".into(),
1039 "{effort}".into(),
1040 "{prompt}".into(),
1041 ],
1042 model: None,
1043 effort: None,
1044 },
1045 ),
1046 ]),
1047 }
1048 }
1049
1050 fn settle(store: &LocalSqlite, id: &str, state: &str) {
1054 let changed = store
1055 .db()
1056 .lock()
1057 .execute(
1058 "UPDATE tickets SET state = ?2 WHERE id = ?1",
1059 rusqlite::params![id, state],
1060 )
1061 .unwrap();
1062 assert_eq!(changed, 1);
1063 }
1064
1065 #[test]
1066 fn reposting_a_settled_ticket_refreshes_content_without_queuing_an_trigger() {
1067 for state in ["merged", "failed", "needs_review"] {
1068 let (root, store) = world();
1069 let relative = ".agents/sloop/tickets/settled.md";
1070 let path = root.path().join(relative);
1071 std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1072 handle(
1073 root.path(),
1074 &store,
1075 &post(relative, PostTrigger::Manual),
1076 2_000,
1077 "TICK",
1078 None,
1079 &flows(),
1080 "default",
1081 )
1082 .unwrap();
1083 settle(&store, "TICK-1", state);
1084 let stamped = std::fs::read_to_string(&path).unwrap();
1085 std::fs::write(&path, stamped.replace("# Original", "# Edited")).unwrap();
1086
1087 let response = handle(
1088 root.path(),
1089 &store,
1090 &post(relative, PostTrigger::Auto),
1091 3_000,
1092 "TICK",
1093 None,
1094 &flows(),
1095 "default",
1096 )
1097 .unwrap();
1098
1099 assert_eq!(response["ticket"]["id"], "TICK-1");
1101 assert_eq!(response["created"], false);
1102 assert_eq!(
1103 response["ticket"]["state"], state,
1104 "a repost must not resurrect a {state} ticket"
1105 );
1106 assert!(
1107 store
1108 .ticket("TICK-1")
1109 .unwrap()
1110 .unwrap()
1111 .body
1112 .unwrap()
1113 .contains("# Edited")
1114 );
1115 assert!(response["trigger"].is_null());
1116 assert_eq!(
1117 response["trigger_suppressed"],
1118 serde_json::json!({"reason": "terminal_ticket", "state": state})
1119 );
1120 assert!(store.queued_triggers().unwrap().is_empty());
1121 }
1122 }
1123
1124 #[test]
1125 fn reposting_a_settled_ticket_with_at_neither_creates_nor_reschedules_an_trigger() {
1126 let (root, store) = world();
1127 let relative = ".agents/sloop/tickets/timed.md";
1128 std::fs::write(root.path().join(relative), ticket("", "# Timed\n")).unwrap();
1129 let args = post(
1130 relative,
1131 PostTrigger::At {
1132 time: "03:00".into(),
1133 },
1134 );
1135 let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1136 assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1137 settle(&store, "TICK-1", "merged");
1138
1139 let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1140
1141 assert!(second["trigger"].is_null());
1142 assert_eq!(second["trigger_suppressed"]["state"], "merged");
1143 let queued = store.queued_triggers().unwrap();
1146 assert_eq!(queued.len(), 1);
1147 assert_eq!(queued[0].eligible_at_ms, Some(10_000));
1148 }
1149
1150 #[test]
1151 fn posting_twice_reuses_the_registration_and_trigger() {
1152 let (root, store) = world();
1153 std::fs::write(
1154 root.path().join(".agents/sloop/tickets/cooldown.md"),
1155 ticket("", "# Cooldowns\n"),
1156 )
1157 .unwrap();
1158 let args = post(".agents/sloop/tickets/cooldown.md", PostTrigger::Auto);
1159
1160 let first = handle(
1161 root.path(),
1162 &store,
1163 &args,
1164 2_000,
1165 "TICK",
1166 None,
1167 &flows(),
1168 "default",
1169 )
1170 .unwrap();
1171 let second = handle(
1172 root.path(),
1173 &store,
1174 &args,
1175 3_000,
1176 "TICK",
1177 None,
1178 &flows(),
1179 "default",
1180 )
1181 .unwrap();
1182 assert_eq!(first["ticket"]["id"], second["ticket"]["id"]);
1183 assert_eq!(first["trigger"]["id"], second["trigger"]["id"]);
1184 let db = store.db();
1185 let connection = db.lock();
1186 let tickets: i64 = connection
1187 .query_row("SELECT COUNT(*) FROM tickets", [], |row| row.get(0))
1188 .unwrap();
1189 let triggers: i64 = connection
1190 .query_row("SELECT COUNT(*) FROM triggers", [], |row| row.get(0))
1191 .unwrap();
1192 assert_eq!(tickets, 1);
1193 assert_eq!(triggers, 1);
1194 }
1195
1196 #[test]
1197 fn stale_source_version_rejects_update_without_clobbering_the_file() {
1198 let (root, store) = world();
1199 let relative = ".agents/sloop/tickets/cas.md";
1200 let path = root.path().join(relative);
1201 std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1202 handle(
1203 root.path(),
1204 &store,
1205 &post(relative, PostTrigger::Manual),
1206 2_000,
1207 "TICK",
1208 None,
1209 &flows(),
1210 "default",
1211 )
1212 .unwrap();
1213 let original = std::fs::read_to_string(&path).unwrap();
1214 let replacement = original.replace("name: Test ticket", "name: Replacement");
1215 let expected = source_version(&original);
1216 let external_edit = original.replace("# Original", "# External edit");
1217 std::fs::write(&path, &external_edit).unwrap();
1218 let author = MarkdownWorkStateAuthor {
1219 root: root.path(),
1220 file_path: relative,
1221 worktree: "cas",
1222 work_state: &store,
1223 original_content: &original,
1224 final_content: &replacement,
1225 original_version: expected.clone(),
1226 trigger: None,
1227 now_ms: 3_000,
1228 trigger_result: std::sync::Mutex::new(serde_json::Value::Null),
1229 };
1230 let content = WorkTicket {
1231 id: "TICK-1".into(),
1232 project_id: "default".into(),
1233 name: "Replacement".into(),
1234 body: "# Replacement\n".into(),
1235 state: WorkTicketState::Ready,
1236 blocked_by: Vec::new(),
1237 attempts: 0,
1238 hints: ExecutionHints {
1239 worktree: Some("sloop/TICK-1".into()),
1240 trigger_id: None,
1241 target: None,
1242 model: None,
1243 effort: None,
1244 flow: Some("default".into()),
1245 },
1246 version: source_version(&replacement),
1247 };
1248 let ticket_ref = TicketRef {
1249 id: content.id.clone(),
1250 source: "local".into(),
1251 source_ref: Some(relative.into()),
1252 };
1253
1254 let error = tokio::runtime::Runtime::new()
1255 .unwrap()
1256 .block_on(author.update(&ticket_ref, &content, &expected))
1257 .unwrap_err();
1258
1259 assert!(matches!(
1260 error,
1261 SourceError::Rejected { message } if message.contains("source version conflict")
1262 ));
1263 assert_eq!(std::fs::read_to_string(path).unwrap(), external_edit);
1264 assert_eq!(store.ticket("TICK-1").unwrap().unwrap().name, "Test ticket");
1265 }
1266
1267 #[test]
1268 fn trigger_insert_failure_leaves_idless_file_and_database_unchanged() {
1269 let (root, store) = world();
1270 let relative = ".agents/sloop/tickets/fail.md";
1271 let path = root.path().join(relative);
1272 let original = ticket("", "# Failure\n");
1273 std::fs::write(&path, &original).unwrap();
1274 store
1275 .db()
1276 .lock()
1277 .execute_batch(
1278 "CREATE TRIGGER reject_trigger BEFORE INSERT ON triggers
1279 BEGIN SELECT RAISE(ABORT, 'forced trigger failure'); END;",
1280 )
1281 .unwrap();
1282
1283 let error = handle(
1284 root.path(),
1285 &store,
1286 &post(relative, PostTrigger::Auto),
1287 2_000,
1288 "TICK",
1289 None,
1290 &flows(),
1291 "default",
1292 )
1293 .unwrap_err();
1294
1295 assert!(error.to_string().contains("forced trigger failure"));
1296 assert_eq!(std::fs::read_to_string(path).unwrap(), original);
1297 assert!(store.ticket_ids().unwrap().is_empty());
1298 assert!(store.queued_triggers().unwrap().is_empty());
1299 let next_ordinal: i64 = store
1300 .db()
1301 .lock()
1302 .query_row(
1303 "SELECT next_ordinal FROM id_counters WHERE kind = 'trigger'",
1304 [],
1305 |row| row.get(0),
1306 )
1307 .unwrap();
1308 assert_eq!(next_ordinal, 1);
1309 }
1310
1311 #[test]
1312 fn posting_at_queues_a_timed_trigger_and_reposting_reschedules_it() {
1313 let (root, store) = world();
1314 std::fs::write(
1315 root.path().join(".agents/sloop/tickets/timed.md"),
1316 ticket("", "# Timed\n"),
1317 )
1318 .unwrap();
1319 let args = post(
1320 ".agents/sloop/tickets/timed.md",
1321 PostTrigger::At {
1322 time: "03:00".into(),
1323 },
1324 );
1325
1326 let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1327 assert_eq!(first["ticket"]["state"], "ready");
1328 assert_eq!(first["trigger"]["kind"], "at");
1329 assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1330
1331 let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1332 assert_eq!(second["trigger"]["id"], first["trigger"]["id"]);
1333 assert_eq!(second["trigger"]["eligible_at_ms"], 20_000);
1334
1335 let queued = store.queued_triggers().unwrap();
1336 assert_eq!(queued.len(), 1);
1337 assert_eq!(queued[0].eligible_at_ms, Some(20_000));
1338 }
1339
1340 #[test]
1341 fn posting_snapshots_the_default_target_and_reposting_refreshes_execution_values() {
1342 let (root, store) = world();
1343 let path = root.path().join(".agents/sloop/tickets/work.md");
1344 std::fs::write(&path, ticket("model: sonnet\neffort: medium\n", "# Work\n")).unwrap();
1345 let args = post(".agents/sloop/tickets/work.md", PostTrigger::Manual);
1346 let agent = agent();
1347
1348 let first = handle(
1349 root.path(),
1350 &store,
1351 &args,
1352 2_000,
1353 "TICK",
1354 Some(&agent),
1355 &flows(),
1356 "default",
1357 )
1358 .unwrap();
1359 assert_eq!(first["ticket"]["target"], "claude");
1360
1361 std::fs::write(
1362 &path,
1363 ticket(
1364 "id: TICK-1\nproject: default\ntarget: codex\nmodel: o3\neffort: high\n",
1365 "# Work\n",
1366 ),
1367 )
1368 .unwrap();
1369 let second = handle(
1370 root.path(),
1371 &store,
1372 &args,
1373 3_000,
1374 "TICK",
1375 Some(&agent),
1376 &flows(),
1377 "default",
1378 )
1379 .unwrap();
1380 assert_eq!(second["ticket"]["id"], first["ticket"]["id"]);
1381 assert_eq!(second["ticket"]["target"], "codex");
1382 assert_eq!(second["ticket"]["model"], "o3");
1383 assert_eq!(second["ticket"]["effort"], "high");
1384 }
1385
1386 #[test]
1387 fn unknown_targets_are_rejected_before_registration_or_trigger() {
1388 let (root, store) = world();
1389 std::fs::write(
1390 root.path().join(".agents/sloop/tickets/work.md"),
1391 ticket("target: missing\n", "# Work\n"),
1392 )
1393 .unwrap();
1394 let args = post(".agents/sloop/tickets/work.md", PostTrigger::Auto);
1395
1396 assert!(matches!(
1397 handle(root.path(), &store, &args, 2_000, "TICK", Some(&agent()), &flows(), "default"),
1398 Err(PostError::UnknownTarget(target)) if target == "missing"
1399 ));
1400 assert!(store.ticket_ids().unwrap().is_empty());
1401 assert!(store.queued_triggers().unwrap().is_empty());
1402 }
1403
1404 #[test]
1405 fn selected_target_placeholders_require_ticket_values_before_registration() {
1406 let (root, store) = world();
1407 std::fs::write(
1408 root.path().join(".agents/sloop/tickets/work.md"),
1409 ticket("target: codex\neffort: high\n", "# Work\n"),
1410 )
1411 .unwrap();
1412 let args = post(".agents/sloop/tickets/work.md", PostTrigger::Manual);
1413
1414 let error = handle(
1415 root.path(),
1416 &store,
1417 &args,
1418 2_000,
1419 "TICK",
1420 Some(&agent()),
1421 &flows(),
1422 "default",
1423 )
1424 .unwrap_err()
1425 .to_string();
1426 assert!(error.contains("agent target `codex`"), "{error}");
1427 assert!(error.contains("does not specify `model`"), "{error}");
1428 assert!(store.ticket_ids().unwrap().is_empty());
1429 }
1430
1431 #[test]
1432 fn a_stamped_project_mismatching_the_request_is_a_conflict() {
1433 let (root, store) = world();
1434 std::fs::write(
1435 root.path().join(".agents/sloop/tickets/t.md"),
1436 ticket("id: T1\nproject: default\n", "# Work\n"),
1437 )
1438 .unwrap();
1439 let args = PostArgs {
1440 file: ".agents/sloop/tickets/t.md".into(),
1441 project: Some("other".into()),
1442 flow: None,
1443 trigger: PostTrigger::Manual,
1444 };
1445
1446 assert!(matches!(
1447 handle(
1448 root.path(),
1449 &store,
1450 &args,
1451 2_000,
1452 "TICK",
1453 None,
1454 &flows(),
1455 "default"
1456 ),
1457 Err(PostError::ProjectConflict { .. })
1458 ));
1459 }
1460
1461 #[test]
1462 fn an_unknown_project_is_rejected() {
1463 let (root, store) = world();
1464 std::fs::write(
1465 root.path().join(".agents/sloop/tickets/t.md"),
1466 ticket("", "# T\n"),
1467 )
1468 .unwrap();
1469 let args = PostArgs {
1470 file: ".agents/sloop/tickets/t.md".into(),
1471 project: Some("missing".into()),
1472 flow: None,
1473 trigger: PostTrigger::Manual,
1474 };
1475
1476 assert!(matches!(
1477 handle(root.path(), &store, &args, 2_000, "TICK", None, &flows(), "default"),
1478 Err(PostError::UnknownProject(project)) if project == "missing"
1479 ));
1480 }
1481
1482 #[test]
1483 fn a_missing_flow_is_stamped_with_the_default() {
1484 let (root, store) = world();
1485 let path = root.path().join(".agents/sloop/tickets/t.md");
1486 std::fs::write(&path, ticket("", "# T\n")).unwrap();
1487 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1488
1489 let response = handle(
1490 root.path(),
1491 &store,
1492 &args,
1493 2_000,
1494 "TICK",
1495 None,
1496 &flows(),
1497 "default",
1498 )
1499 .unwrap();
1500
1501 assert_eq!(response["ticket"]["flow"], "default");
1502 assert!(
1503 std::fs::read_to_string(&path)
1504 .unwrap()
1505 .contains("flow: default")
1506 );
1507 }
1508
1509 #[test]
1510 fn an_explicit_flow_is_honored() {
1511 let (root, store) = world();
1512 std::fs::write(
1513 root.path().join(".agents/sloop/tickets/t.md"),
1514 ticket("flow: release\n", "# T\n"),
1515 )
1516 .unwrap();
1517 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1518
1519 let response = handle(
1520 root.path(),
1521 &store,
1522 &args,
1523 2_000,
1524 "TICK",
1525 None,
1526 &flows(),
1527 "default",
1528 )
1529 .unwrap();
1530
1531 assert_eq!(response["ticket"]["flow"], "release");
1532 }
1533
1534 #[test]
1535 fn a_stamped_flow_mismatching_the_request_is_a_conflict() {
1536 let (root, store) = world();
1537 std::fs::write(
1538 root.path().join(".agents/sloop/tickets/t.md"),
1539 ticket("flow: release\n", "# T\n"),
1540 )
1541 .unwrap();
1542 let args = PostArgs {
1543 file: ".agents/sloop/tickets/t.md".into(),
1544 project: None,
1545 flow: Some("default".into()),
1546 trigger: PostTrigger::Manual,
1547 };
1548
1549 assert!(matches!(
1550 handle(
1551 root.path(),
1552 &store,
1553 &args,
1554 2_000,
1555 "TICK",
1556 None,
1557 &flows(),
1558 "default"
1559 ),
1560 Err(PostError::FlowConflict { .. })
1561 ));
1562 }
1563
1564 #[test]
1565 fn an_unknown_flow_is_rejected_and_names_known_flows() {
1566 let (root, store) = world();
1567 std::fs::write(
1568 root.path().join(".agents/sloop/tickets/t.md"),
1569 ticket("flow: bogus\n", "# T\n"),
1570 )
1571 .unwrap();
1572 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1573
1574 let error = handle(
1575 root.path(),
1576 &store,
1577 &args,
1578 2_000,
1579 "TICK",
1580 None,
1581 &flows(),
1582 "default",
1583 )
1584 .unwrap_err()
1585 .to_string();
1586 assert!(error.contains("bogus"), "{error}");
1587 assert!(error.contains("default"), "{error}");
1588 assert!(error.contains("release"), "{error}");
1589 assert!(store.ticket_ids().unwrap().is_empty());
1590 }
1591
1592 #[test]
1593 fn reindex_recovers_the_flow_binding_from_frontmatter_into_a_fresh_store() {
1594 let (root, store) = world();
1595 std::fs::write(
1596 root.path().join(".agents/sloop/tickets/t.md"),
1597 ticket("", "# T\n"),
1598 )
1599 .unwrap();
1600 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1601 handle(
1602 root.path(),
1603 &store,
1604 &args,
1605 2_000,
1606 "TICK",
1607 None,
1608 &flows(),
1609 "default",
1610 )
1611 .unwrap();
1612 drop(store);
1613
1614 let fresh_store =
1617 LocalSqlite::from_db(Db::open(&root.path().join("fresh.db"), 3_000).unwrap());
1618 fresh_store
1619 .upsert_local_project(
1620 "default",
1621 ".agents/sloop/projects/default.md",
1622 "Default",
1623 3_000,
1624 )
1625 .unwrap();
1626 let response = handle(
1627 root.path(),
1628 &fresh_store,
1629 &args,
1630 3_000,
1631 "TICK",
1632 None,
1633 &flows(),
1634 "default",
1635 )
1636 .unwrap();
1637
1638 assert_eq!(response["ticket"]["id"], "TICK-1");
1639 assert_eq!(response["ticket"]["flow"], "default");
1640 }
1641
1642 #[test]
1643 fn idless_tickets_get_monotonic_generated_ids() {
1644 let (root, store) = world();
1645 std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1646 std::fs::write(
1647 root.path().join(".agents/sloop/tickets/fix.md"),
1648 ticket("", "# A\n"),
1649 )
1650 .unwrap();
1651 std::fs::write(
1652 root.path().join(".agents/sloop/tickets/nested/fix.md"),
1653 ticket("", "# B\n"),
1654 )
1655 .unwrap();
1656
1657 let first = handle(
1658 root.path(),
1659 &store,
1660 &post(".agents/sloop/tickets/fix.md", PostTrigger::Manual),
1661 2_000,
1662 "TICK",
1663 None,
1664 &flows(),
1665 "default",
1666 )
1667 .unwrap();
1668 let second = handle(
1669 root.path(),
1670 &store,
1671 &post(".agents/sloop/tickets/nested/fix.md", PostTrigger::Manual),
1672 2_100,
1673 "TICK",
1674 None,
1675 &flows(),
1676 "default",
1677 )
1678 .unwrap();
1679 assert_eq!(first["ticket"]["id"], "TICK-1");
1680 assert_eq!(second["ticket"]["id"], "TICK-2");
1681 }
1682
1683 #[test]
1684 fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1685 let (root, store) = world();
1686 let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1687 let explicit_content = ticket(
1688 "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1689 "# Explicit\n",
1690 );
1691 std::fs::write(&explicit, &explicit_content).unwrap();
1692 handle(
1693 root.path(),
1694 &store,
1695 &post(".agents/sloop/tickets/explicit.md", PostTrigger::Manual),
1696 2_000,
1697 "WORK",
1698 None,
1699 &flows(),
1700 "default",
1701 )
1702 .unwrap();
1703 assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1704
1705 std::fs::write(
1706 root.path().join(".agents/sloop/tickets/unrelated.md"),
1707 ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1708 )
1709 .unwrap();
1710 handle(
1711 root.path(),
1712 &store,
1713 &post(".agents/sloop/tickets/unrelated.md", PostTrigger::Manual),
1714 2_100,
1715 "WORK",
1716 None,
1717 &flows(),
1718 "default",
1719 )
1720 .unwrap();
1721
1722 std::fs::write(
1723 root.path().join(".agents/sloop/tickets/generated.md"),
1724 ticket("", "# Generated\n"),
1725 )
1726 .unwrap();
1727 let generated = handle(
1728 root.path(),
1729 &store,
1730 &post(".agents/sloop/tickets/generated.md", PostTrigger::Manual),
1731 2_200,
1732 "WORK",
1733 None,
1734 &flows(),
1735 "default",
1736 )
1737 .unwrap();
1738 assert_eq!(generated["ticket"]["id"], "WORK-10");
1739 }
1740
1741 #[test]
1742 fn paths_escaping_the_repository_are_rejected() {
1743 let (root, store) = world();
1744 let args = post("../outside.md", PostTrigger::Manual);
1745
1746 assert!(matches!(
1747 handle(
1748 root.path(),
1749 &store,
1750 &args,
1751 2_000,
1752 "TICK",
1753 None,
1754 &flows(),
1755 "default"
1756 ),
1757 Err(PostError::OutsideRepository(_))
1758 ));
1759 }
1760
1761 #[test]
1766 fn absolute_paths_through_a_symlinked_root_stay_inside_the_repository() {
1767 let (root, store) = world();
1768 let link = tempdir().unwrap();
1769 let linked_root = link.path().join("repository");
1770 std::os::unix::fs::symlink(root.path(), &linked_root).unwrap();
1771
1772 let ticket = linked_root.join(".agents/sloop/tickets/linked.md");
1773 std::fs::write(&ticket, "---\nname: Linked\nblocked_by: []\n---\n\nBody\n").unwrap();
1774
1775 let posted = handle(
1776 root.path(),
1777 &store,
1778 &post(ticket.to_str().unwrap(), PostTrigger::Manual),
1779 2_000,
1780 "TICK",
1781 None,
1782 &flows(),
1783 "default",
1784 )
1785 .unwrap();
1786 assert_eq!(posted["ticket"]["name"], "Linked");
1787 }
1788
1789 #[test]
1793 fn ticket_files_symlinked_out_of_the_repository_are_rejected() {
1794 let (root, store) = world();
1795 let outside = tempdir().unwrap();
1796 let target = outside.path().join("elsewhere.md");
1797 std::fs::write(
1798 &target,
1799 "---\nname: Elsewhere\nblocked_by: []\n---\n\nBody\n",
1800 )
1801 .unwrap();
1802 std::os::unix::fs::symlink(&target, root.path().join(".agents/sloop/tickets/escape.md"))
1803 .unwrap();
1804
1805 assert!(matches!(
1806 handle(
1807 root.path(),
1808 &store,
1809 &post(".agents/sloop/tickets/escape.md", PostTrigger::Manual),
1810 2_000,
1811 "TICK",
1812 None,
1813 &flows(),
1814 "default",
1815 ),
1816 Err(PostError::OutsideRepository(_))
1817 ));
1818 }
1819
1820 #[test]
1823 fn missing_ticket_files_inside_the_directory_still_report_not_found() {
1824 let (root, store) = world();
1825
1826 assert!(matches!(
1827 handle(
1828 root.path(),
1829 &store,
1830 &post(".agents/sloop/tickets/absent.md", PostTrigger::Manual),
1831 2_000,
1832 "TICK",
1833 None,
1834 &flows(),
1835 "default",
1836 ),
1837 Err(PostError::TicketFileNotFound(_))
1838 ));
1839 }
1840
1841 #[test]
1842 fn paths_outside_the_ticket_directory_are_rejected() {
1843 let (root, store) = world();
1844 std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1845
1846 assert!(matches!(
1847 handle(
1848 root.path(),
1849 &store,
1850 &post("elsewhere.md", PostTrigger::Manual),
1851 2_000,
1852 "TICK",
1853 None,
1854 &flows(),
1855 "default",
1856 ),
1857 Err(PostError::OutsideTicketDirectory { .. })
1858 ));
1859 }
1860}