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
399 .as_ref()
400 .map(|ticket| ticket.state.clone())
401 .filter(|state| matches!(state.as_str(), "merged" | "failed" | "needs_review"));
402 let trigger_request = if terminal_state.is_some() {
403 None
404 } else {
405 match &args.trigger {
406 PostTrigger::Manual | PostTrigger::Hold => None,
407 PostTrigger::Auto => Some(TriggerRequest {
408 kind: TriggerKind::Auto,
409 eligible_at_ms: None,
410 }),
411 PostTrigger::At { .. } => Some(TriggerRequest {
412 kind: TriggerKind::At,
413 eligible_at_ms: Some(
414 at_eligible_ms.expect("the dispatcher computes eligibility for at triggers"),
415 ),
416 }),
417 }
418 };
419 let work_ticket = WorkTicket {
420 id: ticket_id.clone(),
421 project_id: project.clone(),
422 name: stamped.name.clone(),
423 body: frontmatter::body(&content)
424 .expect("validated frontmatter has a body")
425 .to_owned(),
426 state: WorkTicketState::from_ticket_state(
427 initial_state,
428 false,
429 String::new(),
430 crate::domain::work::OwnerId(String::new()),
431 ),
432 blocked_by: stamped.blocked_by.clone(),
433 attempts: existing.as_ref().map_or(0, |ticket| ticket.attempts as u32),
434 hints: ExecutionHints {
435 worktree: Some(worktree.clone()),
436 trigger_id: None,
437 target,
438 model: stamped.model.clone(),
439 effort: stamped.effort.clone(),
440 flow: Some(flow_name.clone()),
441 },
442 version: source_version(&final_content),
443 };
444 let author = MarkdownWorkStateAuthor {
445 root,
446 file_path: &relative_str,
447 worktree: &worktree,
448 work_state,
449 original_content: &content,
450 final_content: &final_content,
451 original_version: source_version(&content),
452 trigger: trigger_request,
453 now_ms,
454 trigger_result: Mutex::new(Value::Null),
455 };
456 let ticket_ref = TicketRef {
457 id: ticket_id,
458 source: "local".into(),
459 source_ref: Some(relative_str.clone()),
460 };
461 let created = existing.is_none();
462 if created {
463 author.post(&work_ticket).await?;
464 } else {
465 author
466 .update(&ticket_ref, &work_ticket, &author.original_version)
467 .await?;
468 }
469 let trigger = author.trigger_result();
470 let ticket = work_state
471 .ticket(&work_ticket.id)?
472 .expect("registered ticket still exists");
473
474 Ok(json!({
475 "ticket": {
476 "id": ticket.id,
477 "project": project,
478 "file": relative_str,
479 "state": ticket.state,
480 "name": ticket.name,
481 "blocked_by": ticket.blocked_by,
482 "worktree": ticket.worktree,
483 "target": ticket.target,
484 "model": ticket.model,
485 "effort": ticket.effort,
486 "flow": ticket.flow,
487 },
488 "created": created,
489 "trigger": trigger,
490 "trigger_suppressed": terminal_state.map(|state| json!({
491 "reason": "terminal_ticket",
492 "state": state,
493 })),
494 }))
495}
496
497pub(crate) fn parse_ticket_frontmatter(
511 content: &str,
512 path: &str,
513) -> Result<frontmatter::Frontmatter, PostError> {
514 let (stamped, field_errors) =
515 frontmatter::parse_collecting(content).map_err(|error| PostError::InvalidTicket {
516 path: path.to_owned(),
517 error,
518 })?;
519
520 let name_is_reported = field_errors
521 .iter()
522 .any(|error| matches!(error, FrontmatterError::InvalidFieldType { key } if key == "name"));
523 let blocked_by_is_reported = field_errors
524 .iter()
525 .any(|error| matches!(error, FrontmatterError::InvalidBlockedBy));
526
527 let mut problems = Vec::new();
528 if !name_is_reported && stamped.name.trim().is_empty() {
529 problems.push(TicketProblem::MissingName);
530 }
531 if !blocked_by_is_reported && !stamped.has_blocked_by() {
532 problems.push(TicketProblem::MissingBlockedBy);
533 }
534 if frontmatter::body(content)
535 .expect("frontmatter was already parsed")
536 .trim()
537 .is_empty()
538 {
539 problems.push(TicketProblem::EmptyBody);
540 }
541 problems.extend(field_errors.into_iter().map(TicketProblem::from));
542
543 if problems.is_empty() {
544 Ok(stamped)
545 } else {
546 Err(PostError::InvalidTicketFields {
547 path: path.to_owned(),
548 problems,
549 })
550 }
551}
552
553fn queue_trigger_transaction(
562 transaction: &Transaction<'_>,
563 ticket_id: &str,
564 request: Option<TriggerRequest>,
565 now_ms: i64,
566) -> Result<Value, StoreError> {
567 let Some(request) = request else {
568 return Ok(Value::Null);
569 };
570 let id = trigger::enqueue(
571 transaction,
572 &EnqueueRequest {
573 kind: request.kind,
574 ticket_id: Some(ticket_id),
575 project_id: None,
576 eligible_at_ms: request.eligible_at_ms,
577 interval_ms: None,
578 filters: &[],
579 duplicates: Duplicates::Reuse,
580 },
581 now_ms,
582 )?
583 .id;
584 let mut trigger = json!({
585 "id": id,
586 "kind": request.kind.as_str(),
587 "state": "queued",
588 "ticket": ticket_id,
589 });
590 if let Some(eligible_at_ms) = request.eligible_at_ms {
591 trigger["eligible_at_ms"] = json!(eligible_at_ms);
592 }
593 Ok(trigger)
594}
595
596fn source_store_error(error: StoreError) -> SourceError {
597 if error.is_disk_full() {
598 SourceError::Unavailable { retry_after: None }
599 } else {
600 SourceError::Corrupt {
601 message: error.to_string(),
602 }
603 }
604}
605
606fn source_version(content: &str) -> SourceVersion {
607 let mut hash = 0xcbf29ce484222325_u64;
608 for byte in content.as_bytes() {
609 hash ^= u64::from(*byte);
610 hash = hash.wrapping_mul(0x100000001b3);
611 }
612 SourceVersion(format!("{hash:016x}"))
613}
614
615fn allocate_ticket_id(work_state: &LocalSqlite, prefix: &str) -> Result<String, PostError> {
616 let ids = work_state.ticket_ids()?;
617 next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
618}
619
620fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
623 let path = Path::new(file);
624 let joined = if path.is_absolute() {
625 path.to_path_buf()
626 } else {
627 root.join(path)
628 };
629
630 let mut normalized = PathBuf::new();
631 for component in joined.components() {
632 match component {
633 Component::CurDir => {}
634 Component::ParentDir => {
635 if !normalized.pop() {
636 return Err(PostError::OutsideRepository(file.to_owned()));
637 }
638 }
639 component => normalized.push(component),
640 }
641 }
642 let relative = resolve_symlinks(&normalized)
643 .strip_prefix(resolve_symlinks(root))
644 .map(Path::to_path_buf)
645 .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
646 if !relative.starts_with(ticket_dir) {
647 return Err(PostError::OutsideTicketDirectory {
648 path: file.to_owned(),
649 directory: ticket_dir.to_path_buf(),
650 });
651 }
652 Ok(relative)
653}
654
655fn resolve_symlinks(path: &Path) -> PathBuf {
667 let mut unresolved = Vec::new();
668 let mut prefix = path;
669 loop {
670 if let Ok(resolved) = prefix.canonicalize() {
671 return unresolved
672 .iter()
673 .rev()
674 .fold(resolved, |base, component| base.join(component));
675 }
676 match (prefix.parent(), prefix.file_name()) {
677 (Some(parent), Some(name)) => {
678 unresolved.push(name);
679 prefix = parent;
680 }
681 _ => return path.to_path_buf(),
682 }
683 }
684}
685
686#[derive(Debug)]
689pub enum TicketProblem {
690 Frontmatter(FrontmatterError),
691 MissingName,
692 MissingBlockedBy,
693 InvalidBlockedBy,
694 EmptyBody,
695}
696
697impl From<FrontmatterError> for TicketProblem {
698 fn from(error: FrontmatterError) -> Self {
699 match error {
700 FrontmatterError::InvalidBlockedBy => Self::InvalidBlockedBy,
701 error => Self::Frontmatter(error),
702 }
703 }
704}
705
706impl fmt::Display for TicketProblem {
707 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
708 match self {
709 Self::Frontmatter(error) => error.fmt(formatter),
710 Self::MissingName => {
711 formatter.write_str("missing or empty `name`; add `name: Your ticket title`")
712 }
713 Self::MissingBlockedBy => formatter.write_str(
714 "missing `blocked_by`; add `blocked_by: []` if there are no dependencies",
715 ),
716 Self::InvalidBlockedBy => formatter.write_str(
717 "invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs",
718 ),
719 Self::EmptyBody => {
720 formatter.write_str("empty `body`; add a ticket description after the frontmatter")
721 }
722 }
723 }
724}
725
726#[derive(Debug)]
727pub enum PostError {
728 TicketFileNotFound(String),
729 OutsideRepository(String),
730 OutsideTicketDirectory {
731 path: String,
732 directory: PathBuf,
733 },
734 InvalidTicket {
735 path: String,
736 error: FrontmatterError,
737 },
738 InvalidTicketFields {
741 path: String,
742 problems: Vec<TicketProblem>,
743 },
744 InvalidWorktreeStem {
745 path: String,
746 reason: String,
747 },
748 UnknownBlockedBy {
749 ticket: String,
750 blocker: String,
751 },
752 DependencyCycle(Vec<String>),
753 UnknownProject(String),
754 UnknownTarget(String),
755 MissingTargetValue {
756 target: String,
757 message: String,
758 },
759 ProjectConflict {
760 path: String,
761 stamped: String,
762 requested: String,
763 },
764 FlowConflict {
765 path: String,
766 stamped: String,
767 requested: String,
768 },
769 UnknownFlow {
770 flow: String,
771 known: Vec<String>,
772 },
773 TicketIdTaken {
774 id: String,
775 file: String,
776 },
777 Io {
778 path: String,
779 source: io::Error,
780 },
781 Source(SourceError),
782 Store(StoreError),
783 IdAllocation(IdError),
784}
785
786impl From<SourceError> for PostError {
787 fn from(error: SourceError) -> Self {
788 Self::Source(error)
789 }
790}
791
792impl From<StoreError> for PostError {
793 fn from(error: StoreError) -> Self {
794 Self::Store(error)
795 }
796}
797
798impl fmt::Display for PostError {
799 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
800 match self {
801 Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
802 Self::OutsideRepository(path) => {
803 write!(formatter, "`{path}` is outside the repository")
804 }
805 Self::OutsideTicketDirectory { path, directory } => write!(
806 formatter,
807 "`{path}` is outside the {} directory",
808 directory.display()
809 ),
810 Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
811 Self::InvalidTicketFields { path, problems } => match problems.as_slice() {
812 [problem] => write!(formatter, "{path}: {problem}"),
813 problems => {
814 write!(formatter, "{path}:")?;
815 for problem in problems {
816 write!(formatter, "\n - {problem}")?;
817 }
818 Ok(())
819 }
820 },
821 Self::InvalidWorktreeStem { path, reason } => {
822 write!(formatter, "{path}: {reason}")
823 }
824 Self::UnknownBlockedBy { ticket, blocker } => write!(
825 formatter,
826 "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
827 ),
828 Self::DependencyCycle(chain) => write!(
829 formatter,
830 "field `blocked_by` creates a dependency cycle: {}",
831 chain.join(" -> ")
832 ),
833 Self::UnknownProject(project) => {
834 write!(formatter, "project `{project}` is not indexed")
835 }
836 Self::UnknownTarget(target) => {
837 write!(formatter, "agent target `{target}` is not configured")
838 }
839 Self::MissingTargetValue { target, message } => {
840 write!(formatter, "ticket using agent target `{target}` {message}")
841 }
842 Self::ProjectConflict {
843 path,
844 stamped,
845 requested,
846 } => write!(
847 formatter,
848 "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
849 ),
850 Self::FlowConflict {
851 path,
852 stamped,
853 requested,
854 } => write!(
855 formatter,
856 "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
857 ),
858 Self::UnknownFlow { flow, known } => write!(
859 formatter,
860 "flow `{flow}` is not defined; known flows: {}",
861 known.join(", ")
862 ),
863 Self::TicketIdTaken { id, file } => write!(
864 formatter,
865 "ticket ID `{id}` is already registered by `{file}`"
866 ),
867 Self::Io { path, source } => write!(formatter, "{path}: {source}"),
868 Self::Source(error) => error.fmt(formatter),
869 Self::Store(error) => error.fmt(formatter),
870 Self::IdAllocation(error) => error.fmt(formatter),
871 }
872 }
873}
874
875impl std::error::Error for PostError {}
876
877#[cfg(test)]
878mod tests {
879 use std::collections::BTreeMap;
880
881 use tempfile::tempdir;
882
883 use super::{
884 MarkdownWorkStateAuthor, PostError, handle as handle_with_directory, source_version,
885 };
886 use crate::config::{AgentConfig, AgentTarget};
887 use crate::db::Db;
888 use crate::domain::work::{ExecutionHints, TicketRef, WorkTicket, WorkTicketState};
889 use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage};
890 use crate::protocol::{PostArgs, PostTrigger};
891 use crate::work_state::local::LocalSqlite;
892 use crate::work_state::{SourceError, WorkStateAuthor};
893
894 fn world() -> (tempfile::TempDir, LocalSqlite) {
895 let root = tempdir().unwrap();
896 std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
897 let store = LocalSqlite::from_db(Db::open(&root.path().join("sloop.db"), 1_000).unwrap());
898 store
899 .upsert_local_project(
900 "default",
901 ".agents/sloop/projects/default.md",
902 "Default",
903 1_000,
904 )
905 .unwrap();
906 (root, store)
907 }
908
909 #[allow(clippy::too_many_arguments)]
910 fn handle(
911 root: &std::path::Path,
912 store: &LocalSqlite,
913 args: &PostArgs,
914 now_ms: i64,
915 ticket_prefix: &str,
916 agent: Option<&AgentConfig>,
917 flows: &BTreeMap<String, Flow>,
918 default_flow: &str,
919 ) -> Result<serde_json::Value, PostError> {
920 tokio::runtime::Runtime::new()
921 .unwrap()
922 .block_on(handle_with_directory(
923 root,
924 std::path::Path::new(".agents/sloop/tickets"),
925 store,
926 args,
927 now_ms,
928 None,
929 ticket_prefix,
930 agent,
931 flows,
932 default_flow,
933 ))
934 }
935
936 fn handle_at(
937 root: &std::path::Path,
938 store: &LocalSqlite,
939 args: &PostArgs,
940 now_ms: i64,
941 at_eligible_ms: i64,
942 ) -> Result<serde_json::Value, PostError> {
943 tokio::runtime::Runtime::new()
944 .unwrap()
945 .block_on(handle_with_directory(
946 root,
947 std::path::Path::new(".agents/sloop/tickets"),
948 store,
949 args,
950 now_ms,
951 Some(at_eligible_ms),
952 "TICK",
953 None,
954 &flows(),
955 "default",
956 ))
957 }
958
959 fn post(file: &str, trigger: PostTrigger) -> PostArgs {
960 PostArgs {
961 file: file.into(),
962 project: None,
963 flow: None,
964 trigger,
965 }
966 }
967
968 fn flows() -> BTreeMap<String, Flow> {
969 BTreeMap::from([
970 (
971 "default".to_owned(),
972 Flow {
973 name: "default".into(),
974 stages: vec![Stage {
975 name: "build".into(),
976 action: Actor::Agent,
977 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
978 fail_action: FailAction::Halt,
979 ff_only: false,
980 }],
981 },
982 ),
983 (
984 "release".to_owned(),
985 Flow {
986 name: "release".into(),
987 stages: vec![Stage {
988 name: "build".into(),
989 action: Actor::Agent,
990 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
991 fail_action: FailAction::Halt,
992 ff_only: false,
993 }],
994 },
995 ),
996 ])
997 }
998
999 fn ticket(frontmatter: &str, body: &str) -> String {
1000 format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
1001 }
1002
1003 fn agent() -> AgentConfig {
1004 AgentConfig {
1005 default_target: "claude".into(),
1006 targets: BTreeMap::from([
1007 (
1008 "claude".into(),
1009 AgentTarget {
1010 cmd: vec!["claude".into(), "{prompt}".into()],
1011 model: None,
1012 effort: None,
1013 },
1014 ),
1015 (
1016 "codex".into(),
1017 AgentTarget {
1018 cmd: vec![
1019 "codex".into(),
1020 "{model}".into(),
1021 "{effort}".into(),
1022 "{prompt}".into(),
1023 ],
1024 model: None,
1025 effort: None,
1026 },
1027 ),
1028 ]),
1029 }
1030 }
1031
1032 fn settle(store: &LocalSqlite, id: &str, state: &str) {
1036 let changed = store
1037 .db()
1038 .lock()
1039 .execute(
1040 "UPDATE tickets SET state = ?2 WHERE id = ?1",
1041 rusqlite::params![id, state],
1042 )
1043 .unwrap();
1044 assert_eq!(changed, 1);
1045 }
1046
1047 #[test]
1048 fn reposting_a_settled_ticket_refreshes_content_without_queuing_an_trigger() {
1049 for state in ["merged", "failed", "needs_review"] {
1050 let (root, store) = world();
1051 let relative = ".agents/sloop/tickets/settled.md";
1052 let path = root.path().join(relative);
1053 std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1054 handle(
1055 root.path(),
1056 &store,
1057 &post(relative, PostTrigger::Manual),
1058 2_000,
1059 "TICK",
1060 None,
1061 &flows(),
1062 "default",
1063 )
1064 .unwrap();
1065 settle(&store, "TICK-1", state);
1066 let stamped = std::fs::read_to_string(&path).unwrap();
1067 std::fs::write(&path, stamped.replace("# Original", "# Edited")).unwrap();
1068
1069 let response = handle(
1070 root.path(),
1071 &store,
1072 &post(relative, PostTrigger::Auto),
1073 3_000,
1074 "TICK",
1075 None,
1076 &flows(),
1077 "default",
1078 )
1079 .unwrap();
1080
1081 assert_eq!(response["ticket"]["id"], "TICK-1");
1082 assert_eq!(response["created"], false);
1083 assert_eq!(
1084 response["ticket"]["state"], state,
1085 "a repost must not resurrect a {state} ticket"
1086 );
1087 assert!(
1088 store
1089 .ticket("TICK-1")
1090 .unwrap()
1091 .unwrap()
1092 .body
1093 .unwrap()
1094 .contains("# Edited")
1095 );
1096 assert!(response["trigger"].is_null());
1097 assert_eq!(
1098 response["trigger_suppressed"],
1099 serde_json::json!({"reason": "terminal_ticket", "state": state})
1100 );
1101 assert!(store.queued_triggers().unwrap().is_empty());
1102 }
1103 }
1104
1105 #[test]
1106 fn reposting_a_settled_ticket_with_at_neither_creates_nor_reschedules_an_trigger() {
1107 let (root, store) = world();
1108 let relative = ".agents/sloop/tickets/timed.md";
1109 std::fs::write(root.path().join(relative), ticket("", "# Timed\n")).unwrap();
1110 let args = post(
1111 relative,
1112 PostTrigger::At {
1113 time: "03:00".into(),
1114 },
1115 );
1116 let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1117 assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1118 settle(&store, "TICK-1", "merged");
1119
1120 let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1121
1122 assert!(second["trigger"].is_null());
1123 assert_eq!(second["trigger_suppressed"]["state"], "merged");
1124 let queued = store.queued_triggers().unwrap();
1125 assert_eq!(queued.len(), 1);
1126 assert_eq!(queued[0].eligible_at_ms, Some(10_000));
1127 }
1128
1129 #[test]
1130 fn posting_twice_reuses_the_registration_and_trigger() {
1131 let (root, store) = world();
1132 std::fs::write(
1133 root.path().join(".agents/sloop/tickets/cooldown.md"),
1134 ticket("", "# Cooldowns\n"),
1135 )
1136 .unwrap();
1137 let args = post(".agents/sloop/tickets/cooldown.md", PostTrigger::Auto);
1138
1139 let first = handle(
1140 root.path(),
1141 &store,
1142 &args,
1143 2_000,
1144 "TICK",
1145 None,
1146 &flows(),
1147 "default",
1148 )
1149 .unwrap();
1150 let second = handle(
1151 root.path(),
1152 &store,
1153 &args,
1154 3_000,
1155 "TICK",
1156 None,
1157 &flows(),
1158 "default",
1159 )
1160 .unwrap();
1161 assert_eq!(first["ticket"]["id"], second["ticket"]["id"]);
1162 assert_eq!(first["trigger"]["id"], second["trigger"]["id"]);
1163 let db = store.db();
1164 let connection = db.lock();
1165 let tickets: i64 = connection
1166 .query_row("SELECT COUNT(*) FROM tickets", [], |row| row.get(0))
1167 .unwrap();
1168 let triggers: i64 = connection
1169 .query_row("SELECT COUNT(*) FROM triggers", [], |row| row.get(0))
1170 .unwrap();
1171 assert_eq!(tickets, 1);
1172 assert_eq!(triggers, 1);
1173 }
1174
1175 #[test]
1176 fn stale_source_version_rejects_update_without_clobbering_the_file() {
1177 let (root, store) = world();
1178 let relative = ".agents/sloop/tickets/cas.md";
1179 let path = root.path().join(relative);
1180 std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1181 handle(
1182 root.path(),
1183 &store,
1184 &post(relative, PostTrigger::Manual),
1185 2_000,
1186 "TICK",
1187 None,
1188 &flows(),
1189 "default",
1190 )
1191 .unwrap();
1192 let original = std::fs::read_to_string(&path).unwrap();
1193 let replacement = original.replace("name: Test ticket", "name: Replacement");
1194 let expected = source_version(&original);
1195 let external_edit = original.replace("# Original", "# External edit");
1196 std::fs::write(&path, &external_edit).unwrap();
1197 let author = MarkdownWorkStateAuthor {
1198 root: root.path(),
1199 file_path: relative,
1200 worktree: "cas",
1201 work_state: &store,
1202 original_content: &original,
1203 final_content: &replacement,
1204 original_version: expected.clone(),
1205 trigger: None,
1206 now_ms: 3_000,
1207 trigger_result: std::sync::Mutex::new(serde_json::Value::Null),
1208 };
1209 let content = WorkTicket {
1210 id: "TICK-1".into(),
1211 project_id: "default".into(),
1212 name: "Replacement".into(),
1213 body: "# Replacement\n".into(),
1214 state: WorkTicketState::Ready,
1215 blocked_by: Vec::new(),
1216 attempts: 0,
1217 hints: ExecutionHints {
1218 worktree: Some("sloop/TICK-1".into()),
1219 trigger_id: None,
1220 target: None,
1221 model: None,
1222 effort: None,
1223 flow: Some("default".into()),
1224 },
1225 version: source_version(&replacement),
1226 };
1227 let ticket_ref = TicketRef {
1228 id: content.id.clone(),
1229 source: "local".into(),
1230 source_ref: Some(relative.into()),
1231 };
1232
1233 let error = tokio::runtime::Runtime::new()
1234 .unwrap()
1235 .block_on(author.update(&ticket_ref, &content, &expected))
1236 .unwrap_err();
1237
1238 assert!(matches!(
1239 error,
1240 SourceError::Rejected { message } if message.contains("source version conflict")
1241 ));
1242 assert_eq!(std::fs::read_to_string(path).unwrap(), external_edit);
1243 assert_eq!(store.ticket("TICK-1").unwrap().unwrap().name, "Test ticket");
1244 }
1245
1246 #[test]
1247 fn trigger_insert_failure_leaves_idless_file_and_database_unchanged() {
1248 let (root, store) = world();
1249 let relative = ".agents/sloop/tickets/fail.md";
1250 let path = root.path().join(relative);
1251 let original = ticket("", "# Failure\n");
1252 std::fs::write(&path, &original).unwrap();
1253 store
1254 .db()
1255 .lock()
1256 .execute_batch(
1257 "CREATE TRIGGER reject_trigger BEFORE INSERT ON triggers
1258 BEGIN SELECT RAISE(ABORT, 'forced trigger failure'); END;",
1259 )
1260 .unwrap();
1261
1262 let error = handle(
1263 root.path(),
1264 &store,
1265 &post(relative, PostTrigger::Auto),
1266 2_000,
1267 "TICK",
1268 None,
1269 &flows(),
1270 "default",
1271 )
1272 .unwrap_err();
1273
1274 assert!(error.to_string().contains("forced trigger failure"));
1275 assert_eq!(std::fs::read_to_string(path).unwrap(), original);
1276 assert!(store.ticket_ids().unwrap().is_empty());
1277 assert!(store.queued_triggers().unwrap().is_empty());
1278 let next_ordinal: i64 = store
1279 .db()
1280 .lock()
1281 .query_row(
1282 "SELECT next_ordinal FROM id_counters WHERE kind = 'trigger'",
1283 [],
1284 |row| row.get(0),
1285 )
1286 .unwrap();
1287 assert_eq!(next_ordinal, 1);
1288 }
1289
1290 #[test]
1291 fn posting_at_queues_a_timed_trigger_and_reposting_reschedules_it() {
1292 let (root, store) = world();
1293 std::fs::write(
1294 root.path().join(".agents/sloop/tickets/timed.md"),
1295 ticket("", "# Timed\n"),
1296 )
1297 .unwrap();
1298 let args = post(
1299 ".agents/sloop/tickets/timed.md",
1300 PostTrigger::At {
1301 time: "03:00".into(),
1302 },
1303 );
1304
1305 let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1306 assert_eq!(first["ticket"]["state"], "ready");
1307 assert_eq!(first["trigger"]["kind"], "at");
1308 assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1309
1310 let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1311 assert_eq!(second["trigger"]["id"], first["trigger"]["id"]);
1312 assert_eq!(second["trigger"]["eligible_at_ms"], 20_000);
1313
1314 let queued = store.queued_triggers().unwrap();
1315 assert_eq!(queued.len(), 1);
1316 assert_eq!(queued[0].eligible_at_ms, Some(20_000));
1317 }
1318
1319 #[test]
1320 fn posting_snapshots_the_default_target_and_reposting_refreshes_execution_values() {
1321 let (root, store) = world();
1322 let path = root.path().join(".agents/sloop/tickets/work.md");
1323 std::fs::write(&path, ticket("model: sonnet\neffort: medium\n", "# Work\n")).unwrap();
1324 let args = post(".agents/sloop/tickets/work.md", PostTrigger::Manual);
1325 let agent = agent();
1326
1327 let first = handle(
1328 root.path(),
1329 &store,
1330 &args,
1331 2_000,
1332 "TICK",
1333 Some(&agent),
1334 &flows(),
1335 "default",
1336 )
1337 .unwrap();
1338 assert_eq!(first["ticket"]["target"], "claude");
1339
1340 std::fs::write(
1341 &path,
1342 ticket(
1343 "id: TICK-1\nproject: default\ntarget: codex\nmodel: o3\neffort: high\n",
1344 "# Work\n",
1345 ),
1346 )
1347 .unwrap();
1348 let second = handle(
1349 root.path(),
1350 &store,
1351 &args,
1352 3_000,
1353 "TICK",
1354 Some(&agent),
1355 &flows(),
1356 "default",
1357 )
1358 .unwrap();
1359 assert_eq!(second["ticket"]["id"], first["ticket"]["id"]);
1360 assert_eq!(second["ticket"]["target"], "codex");
1361 assert_eq!(second["ticket"]["model"], "o3");
1362 assert_eq!(second["ticket"]["effort"], "high");
1363 }
1364
1365 #[test]
1366 fn unknown_targets_are_rejected_before_registration_or_trigger() {
1367 let (root, store) = world();
1368 std::fs::write(
1369 root.path().join(".agents/sloop/tickets/work.md"),
1370 ticket("target: missing\n", "# Work\n"),
1371 )
1372 .unwrap();
1373 let args = post(".agents/sloop/tickets/work.md", PostTrigger::Auto);
1374
1375 assert!(matches!(
1376 handle(root.path(), &store, &args, 2_000, "TICK", Some(&agent()), &flows(), "default"),
1377 Err(PostError::UnknownTarget(target)) if target == "missing"
1378 ));
1379 assert!(store.ticket_ids().unwrap().is_empty());
1380 assert!(store.queued_triggers().unwrap().is_empty());
1381 }
1382
1383 #[test]
1384 fn selected_target_placeholders_require_ticket_values_before_registration() {
1385 let (root, store) = world();
1386 std::fs::write(
1387 root.path().join(".agents/sloop/tickets/work.md"),
1388 ticket("target: codex\neffort: high\n", "# Work\n"),
1389 )
1390 .unwrap();
1391 let args = post(".agents/sloop/tickets/work.md", PostTrigger::Manual);
1392
1393 let error = handle(
1394 root.path(),
1395 &store,
1396 &args,
1397 2_000,
1398 "TICK",
1399 Some(&agent()),
1400 &flows(),
1401 "default",
1402 )
1403 .unwrap_err()
1404 .to_string();
1405 assert!(error.contains("agent target `codex`"), "{error}");
1406 assert!(error.contains("does not specify `model`"), "{error}");
1407 assert!(store.ticket_ids().unwrap().is_empty());
1408 }
1409
1410 #[test]
1411 fn a_stamped_project_mismatching_the_request_is_a_conflict() {
1412 let (root, store) = world();
1413 std::fs::write(
1414 root.path().join(".agents/sloop/tickets/t.md"),
1415 ticket("id: T1\nproject: default\n", "# Work\n"),
1416 )
1417 .unwrap();
1418 let args = PostArgs {
1419 file: ".agents/sloop/tickets/t.md".into(),
1420 project: Some("other".into()),
1421 flow: None,
1422 trigger: PostTrigger::Manual,
1423 };
1424
1425 assert!(matches!(
1426 handle(
1427 root.path(),
1428 &store,
1429 &args,
1430 2_000,
1431 "TICK",
1432 None,
1433 &flows(),
1434 "default"
1435 ),
1436 Err(PostError::ProjectConflict { .. })
1437 ));
1438 }
1439
1440 #[test]
1441 fn an_unknown_project_is_rejected() {
1442 let (root, store) = world();
1443 std::fs::write(
1444 root.path().join(".agents/sloop/tickets/t.md"),
1445 ticket("", "# T\n"),
1446 )
1447 .unwrap();
1448 let args = PostArgs {
1449 file: ".agents/sloop/tickets/t.md".into(),
1450 project: Some("missing".into()),
1451 flow: None,
1452 trigger: PostTrigger::Manual,
1453 };
1454
1455 assert!(matches!(
1456 handle(root.path(), &store, &args, 2_000, "TICK", None, &flows(), "default"),
1457 Err(PostError::UnknownProject(project)) if project == "missing"
1458 ));
1459 }
1460
1461 #[test]
1462 fn a_missing_flow_is_stamped_with_the_default() {
1463 let (root, store) = world();
1464 let path = root.path().join(".agents/sloop/tickets/t.md");
1465 std::fs::write(&path, ticket("", "# T\n")).unwrap();
1466 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1467
1468 let response = handle(
1469 root.path(),
1470 &store,
1471 &args,
1472 2_000,
1473 "TICK",
1474 None,
1475 &flows(),
1476 "default",
1477 )
1478 .unwrap();
1479
1480 assert_eq!(response["ticket"]["flow"], "default");
1481 assert!(
1482 std::fs::read_to_string(&path)
1483 .unwrap()
1484 .contains("flow: default")
1485 );
1486 }
1487
1488 #[test]
1489 fn an_explicit_flow_is_honored() {
1490 let (root, store) = world();
1491 std::fs::write(
1492 root.path().join(".agents/sloop/tickets/t.md"),
1493 ticket("flow: release\n", "# T\n"),
1494 )
1495 .unwrap();
1496 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1497
1498 let response = handle(
1499 root.path(),
1500 &store,
1501 &args,
1502 2_000,
1503 "TICK",
1504 None,
1505 &flows(),
1506 "default",
1507 )
1508 .unwrap();
1509
1510 assert_eq!(response["ticket"]["flow"], "release");
1511 }
1512
1513 #[test]
1514 fn a_stamped_flow_mismatching_the_request_is_a_conflict() {
1515 let (root, store) = world();
1516 std::fs::write(
1517 root.path().join(".agents/sloop/tickets/t.md"),
1518 ticket("flow: release\n", "# T\n"),
1519 )
1520 .unwrap();
1521 let args = PostArgs {
1522 file: ".agents/sloop/tickets/t.md".into(),
1523 project: None,
1524 flow: Some("default".into()),
1525 trigger: PostTrigger::Manual,
1526 };
1527
1528 assert!(matches!(
1529 handle(
1530 root.path(),
1531 &store,
1532 &args,
1533 2_000,
1534 "TICK",
1535 None,
1536 &flows(),
1537 "default"
1538 ),
1539 Err(PostError::FlowConflict { .. })
1540 ));
1541 }
1542
1543 #[test]
1544 fn an_unknown_flow_is_rejected_and_names_known_flows() {
1545 let (root, store) = world();
1546 std::fs::write(
1547 root.path().join(".agents/sloop/tickets/t.md"),
1548 ticket("flow: bogus\n", "# T\n"),
1549 )
1550 .unwrap();
1551 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1552
1553 let error = handle(
1554 root.path(),
1555 &store,
1556 &args,
1557 2_000,
1558 "TICK",
1559 None,
1560 &flows(),
1561 "default",
1562 )
1563 .unwrap_err()
1564 .to_string();
1565 assert!(error.contains("bogus"), "{error}");
1566 assert!(error.contains("default"), "{error}");
1567 assert!(error.contains("release"), "{error}");
1568 assert!(store.ticket_ids().unwrap().is_empty());
1569 }
1570
1571 #[test]
1572 fn reindex_recovers_the_flow_binding_from_frontmatter_into_a_fresh_store() {
1573 let (root, store) = world();
1574 std::fs::write(
1575 root.path().join(".agents/sloop/tickets/t.md"),
1576 ticket("", "# T\n"),
1577 )
1578 .unwrap();
1579 let args = post(".agents/sloop/tickets/t.md", PostTrigger::Manual);
1580 handle(
1581 root.path(),
1582 &store,
1583 &args,
1584 2_000,
1585 "TICK",
1586 None,
1587 &flows(),
1588 "default",
1589 )
1590 .unwrap();
1591 drop(store);
1592
1593 let fresh_store =
1594 LocalSqlite::from_db(Db::open(&root.path().join("fresh.db"), 3_000).unwrap());
1595 fresh_store
1596 .upsert_local_project(
1597 "default",
1598 ".agents/sloop/projects/default.md",
1599 "Default",
1600 3_000,
1601 )
1602 .unwrap();
1603 let response = handle(
1604 root.path(),
1605 &fresh_store,
1606 &args,
1607 3_000,
1608 "TICK",
1609 None,
1610 &flows(),
1611 "default",
1612 )
1613 .unwrap();
1614
1615 assert_eq!(response["ticket"]["id"], "TICK-1");
1616 assert_eq!(response["ticket"]["flow"], "default");
1617 }
1618
1619 #[test]
1620 fn idless_tickets_get_monotonic_generated_ids() {
1621 let (root, store) = world();
1622 std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1623 std::fs::write(
1624 root.path().join(".agents/sloop/tickets/fix.md"),
1625 ticket("", "# A\n"),
1626 )
1627 .unwrap();
1628 std::fs::write(
1629 root.path().join(".agents/sloop/tickets/nested/fix.md"),
1630 ticket("", "# B\n"),
1631 )
1632 .unwrap();
1633
1634 let first = handle(
1635 root.path(),
1636 &store,
1637 &post(".agents/sloop/tickets/fix.md", PostTrigger::Manual),
1638 2_000,
1639 "TICK",
1640 None,
1641 &flows(),
1642 "default",
1643 )
1644 .unwrap();
1645 let second = handle(
1646 root.path(),
1647 &store,
1648 &post(".agents/sloop/tickets/nested/fix.md", PostTrigger::Manual),
1649 2_100,
1650 "TICK",
1651 None,
1652 &flows(),
1653 "default",
1654 )
1655 .unwrap();
1656 assert_eq!(first["ticket"]["id"], "TICK-1");
1657 assert_eq!(second["ticket"]["id"], "TICK-2");
1658 }
1659
1660 #[test]
1661 fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1662 let (root, store) = world();
1663 let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1664 let explicit_content = ticket(
1665 "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1666 "# Explicit\n",
1667 );
1668 std::fs::write(&explicit, &explicit_content).unwrap();
1669 handle(
1670 root.path(),
1671 &store,
1672 &post(".agents/sloop/tickets/explicit.md", PostTrigger::Manual),
1673 2_000,
1674 "WORK",
1675 None,
1676 &flows(),
1677 "default",
1678 )
1679 .unwrap();
1680 assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1681
1682 std::fs::write(
1683 root.path().join(".agents/sloop/tickets/unrelated.md"),
1684 ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1685 )
1686 .unwrap();
1687 handle(
1688 root.path(),
1689 &store,
1690 &post(".agents/sloop/tickets/unrelated.md", PostTrigger::Manual),
1691 2_100,
1692 "WORK",
1693 None,
1694 &flows(),
1695 "default",
1696 )
1697 .unwrap();
1698
1699 std::fs::write(
1700 root.path().join(".agents/sloop/tickets/generated.md"),
1701 ticket("", "# Generated\n"),
1702 )
1703 .unwrap();
1704 let generated = handle(
1705 root.path(),
1706 &store,
1707 &post(".agents/sloop/tickets/generated.md", PostTrigger::Manual),
1708 2_200,
1709 "WORK",
1710 None,
1711 &flows(),
1712 "default",
1713 )
1714 .unwrap();
1715 assert_eq!(generated["ticket"]["id"], "WORK-10");
1716 }
1717
1718 #[test]
1719 fn paths_escaping_the_repository_are_rejected() {
1720 let (root, store) = world();
1721 let args = post("../outside.md", PostTrigger::Manual);
1722
1723 assert!(matches!(
1724 handle(
1725 root.path(),
1726 &store,
1727 &args,
1728 2_000,
1729 "TICK",
1730 None,
1731 &flows(),
1732 "default"
1733 ),
1734 Err(PostError::OutsideRepository(_))
1735 ));
1736 }
1737
1738 #[test]
1743 fn absolute_paths_through_a_symlinked_root_stay_inside_the_repository() {
1744 let (root, store) = world();
1745 let link = tempdir().unwrap();
1746 let linked_root = link.path().join("repository");
1747 std::os::unix::fs::symlink(root.path(), &linked_root).unwrap();
1748
1749 let ticket = linked_root.join(".agents/sloop/tickets/linked.md");
1750 std::fs::write(&ticket, "---\nname: Linked\nblocked_by: []\n---\n\nBody\n").unwrap();
1751
1752 let posted = handle(
1753 root.path(),
1754 &store,
1755 &post(ticket.to_str().unwrap(), PostTrigger::Manual),
1756 2_000,
1757 "TICK",
1758 None,
1759 &flows(),
1760 "default",
1761 )
1762 .unwrap();
1763 assert_eq!(posted["ticket"]["name"], "Linked");
1764 }
1765
1766 #[test]
1770 fn ticket_files_symlinked_out_of_the_repository_are_rejected() {
1771 let (root, store) = world();
1772 let outside = tempdir().unwrap();
1773 let target = outside.path().join("elsewhere.md");
1774 std::fs::write(
1775 &target,
1776 "---\nname: Elsewhere\nblocked_by: []\n---\n\nBody\n",
1777 )
1778 .unwrap();
1779 std::os::unix::fs::symlink(&target, root.path().join(".agents/sloop/tickets/escape.md"))
1780 .unwrap();
1781
1782 assert!(matches!(
1783 handle(
1784 root.path(),
1785 &store,
1786 &post(".agents/sloop/tickets/escape.md", PostTrigger::Manual),
1787 2_000,
1788 "TICK",
1789 None,
1790 &flows(),
1791 "default",
1792 ),
1793 Err(PostError::OutsideRepository(_))
1794 ));
1795 }
1796
1797 #[test]
1800 fn missing_ticket_files_inside_the_directory_still_report_not_found() {
1801 let (root, store) = world();
1802
1803 assert!(matches!(
1804 handle(
1805 root.path(),
1806 &store,
1807 &post(".agents/sloop/tickets/absent.md", PostTrigger::Manual),
1808 2_000,
1809 "TICK",
1810 None,
1811 &flows(),
1812 "default",
1813 ),
1814 Err(PostError::TicketFileNotFound(_))
1815 ));
1816 }
1817
1818 #[test]
1819 fn paths_outside_the_ticket_directory_are_rejected() {
1820 let (root, store) = world();
1821 std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1822
1823 assert!(matches!(
1824 handle(
1825 root.path(),
1826 &store,
1827 &post("elsewhere.md", PostTrigger::Manual),
1828 2_000,
1829 "TICK",
1830 None,
1831 &flows(),
1832 "default",
1833 ),
1834 Err(PostError::OutsideTicketDirectory { .. })
1835 ));
1836 }
1837}