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 named: work_state.ticket_by_name(blocker)?.map(|ticket| ticket.id),
370 });
371 }
372 }
373 let mut dependencies = work_state.ticket_dependencies()?;
374 dependencies.insert(ticket_id.clone(), stamped.blocked_by.clone());
375 if let Some(chain) = crate::domain::graph::find_cycle(&dependencies) {
376 return Err(PostError::DependencyCycle(chain));
377 }
378
379 let worktree = match stamped.worktree.clone() {
380 Some(worktree) => worktree,
381 None => {
382 let stem = Path::new(&relative_str)
383 .file_stem()
384 .and_then(|stem| stem.to_str());
385 crate::ids::default_worktree(stem, &ticket_id).map_err(|reason| {
386 PostError::InvalidWorktreeStem {
387 path: relative_str.clone(),
388 reason,
389 }
390 })?
391 }
392 };
393 let stamp = frontmatter::stamp(&content, &ticket_id, &project, &worktree, &flow_name).map_err(
394 |error| PostError::InvalidTicket {
395 path: relative_str.clone(),
396 error,
397 },
398 )?;
399 let file_rewritten = stamp.is_some();
400 let final_content = stamp.unwrap_or_else(|| content.clone());
401 let terminal_state = existing
402 .as_ref()
403 .map(|ticket| ticket.state.clone())
404 .filter(|state| matches!(state.as_str(), "merged" | "failed" | "needs_review"));
405 let trigger_request = if terminal_state.is_some() {
406 None
407 } else {
408 match &args.trigger {
409 PostTrigger::Manual | PostTrigger::Hold => None,
410 PostTrigger::Auto => Some(TriggerRequest {
411 kind: TriggerKind::Auto,
412 eligible_at_ms: None,
413 }),
414 PostTrigger::At { .. } => Some(TriggerRequest {
415 kind: TriggerKind::At,
416 eligible_at_ms: Some(
417 at_eligible_ms.expect("the dispatcher computes eligibility for at triggers"),
418 ),
419 }),
420 }
421 };
422 let work_ticket = WorkTicket {
423 id: ticket_id.clone(),
424 project_id: project.clone(),
425 name: stamped.name.clone(),
426 body: frontmatter::body(&content)
427 .expect("validated frontmatter has a body")
428 .to_owned(),
429 state: WorkTicketState::from_ticket_state(
430 initial_state,
431 false,
432 String::new(),
433 crate::domain::work::OwnerId(String::new()),
434 ),
435 blocked_by: stamped.blocked_by.clone(),
436 attempts: existing.as_ref().map_or(0, |ticket| ticket.attempts as u32),
437 hints: ExecutionHints {
438 worktree: Some(worktree.clone()),
439 trigger_id: None,
440 target,
441 model: stamped.model.clone(),
442 effort: stamped.effort.clone(),
443 flow: Some(flow_name.clone()),
444 },
445 version: source_version(&final_content),
446 };
447 let author = MarkdownWorkStateAuthor {
448 root,
449 file_path: &relative_str,
450 worktree: &worktree,
451 work_state,
452 original_content: &content,
453 final_content: &final_content,
454 original_version: source_version(&content),
455 trigger: trigger_request,
456 now_ms,
457 trigger_result: Mutex::new(Value::Null),
458 };
459 let ticket_ref = TicketRef {
460 id: ticket_id,
461 source: "local".into(),
462 source_ref: Some(relative_str.clone()),
463 };
464 let created = existing.is_none();
465 if created {
466 author.post(&work_ticket).await?;
467 } else {
468 author
469 .update(&ticket_ref, &work_ticket, &author.original_version)
470 .await?;
471 }
472 let trigger = author.trigger_result();
473 let ticket = work_state
474 .ticket(&work_ticket.id)?
475 .expect("registered ticket still exists");
476
477 Ok(json!({
478 "ticket": {
479 "id": ticket.id,
480 "project": project,
481 "file": relative_str,
482 "state": ticket.state,
483 "name": ticket.name,
484 "blocked_by": ticket.blocked_by,
485 "worktree": ticket.worktree,
486 "target": ticket.target,
487 "model": ticket.model,
488 "effort": ticket.effort,
489 "flow": ticket.flow,
490 },
491 "created": created,
492 "file_rewritten": file_rewritten,
493 "trigger": trigger,
494 "trigger_suppressed": terminal_state.map(|state| json!({
495 "reason": "terminal_ticket",
496 "state": state,
497 })),
498 }))
499}
500
501pub(crate) fn parse_ticket_frontmatter(
515 content: &str,
516 path: &str,
517) -> Result<frontmatter::Frontmatter, PostError> {
518 let (stamped, field_errors) =
519 frontmatter::parse_collecting(content).map_err(|error| PostError::InvalidTicket {
520 path: path.to_owned(),
521 error,
522 })?;
523
524 let name_is_reported = field_errors
525 .iter()
526 .any(|error| matches!(error, FrontmatterError::InvalidFieldType { key } if key == "name"));
527 let blocked_by_is_reported = field_errors
528 .iter()
529 .any(|error| matches!(error, FrontmatterError::InvalidBlockedBy));
530
531 let mut problems = Vec::new();
532 if !name_is_reported && stamped.name.trim().is_empty() {
533 problems.push(TicketProblem::MissingName);
534 }
535 if !blocked_by_is_reported && !stamped.has_blocked_by() {
536 problems.push(TicketProblem::MissingBlockedBy);
537 }
538 if frontmatter::body(content)
539 .expect("frontmatter was already parsed")
540 .trim()
541 .is_empty()
542 {
543 problems.push(TicketProblem::EmptyBody);
544 }
545 problems.extend(field_errors.into_iter().map(TicketProblem::from));
546
547 if problems.is_empty() {
548 Ok(stamped)
549 } else {
550 Err(PostError::InvalidTicketFields {
551 path: path.to_owned(),
552 problems,
553 })
554 }
555}
556
557fn queue_trigger_transaction(
566 transaction: &Transaction<'_>,
567 ticket_id: &str,
568 request: Option<TriggerRequest>,
569 now_ms: i64,
570) -> Result<Value, StoreError> {
571 let Some(request) = request else {
572 return Ok(Value::Null);
573 };
574 let id = trigger::enqueue(
575 transaction,
576 &EnqueueRequest {
577 kind: request.kind,
578 ticket_id: Some(ticket_id),
579 project_id: None,
580 eligible_at_ms: request.eligible_at_ms,
581 interval_ms: None,
582 filters: &[],
583 duplicates: Duplicates::Reuse,
584 },
585 now_ms,
586 )?
587 .id;
588 let mut trigger = json!({
589 "id": id,
590 "kind": request.kind.as_str(),
591 "state": "queued",
592 "ticket": ticket_id,
593 });
594 if let Some(eligible_at_ms) = request.eligible_at_ms {
595 trigger["eligible_at_ms"] = json!(eligible_at_ms);
596 }
597 Ok(trigger)
598}
599
600fn source_store_error(error: StoreError) -> SourceError {
601 if error.is_disk_full() {
602 SourceError::Unavailable { retry_after: None }
603 } else {
604 SourceError::Corrupt {
605 message: error.to_string(),
606 }
607 }
608}
609
610fn source_version(content: &str) -> SourceVersion {
611 let mut hash = 0xcbf29ce484222325_u64;
612 for byte in content.as_bytes() {
613 hash ^= u64::from(*byte);
614 hash = hash.wrapping_mul(0x100000001b3);
615 }
616 SourceVersion(format!("{hash:016x}"))
617}
618
619fn allocate_ticket_id(work_state: &LocalSqlite, prefix: &str) -> Result<String, PostError> {
620 let ids = work_state.ticket_ids()?;
621 next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
622}
623
624fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
627 let path = Path::new(file);
628 let joined = if path.is_absolute() {
629 path.to_path_buf()
630 } else {
631 root.join(path)
632 };
633
634 let mut normalized = PathBuf::new();
635 for component in joined.components() {
636 match component {
637 Component::CurDir => {}
638 Component::ParentDir => {
639 if !normalized.pop() {
640 return Err(PostError::OutsideRepository(file.to_owned()));
641 }
642 }
643 component => normalized.push(component),
644 }
645 }
646 let relative = resolve_symlinks(&normalized)
647 .strip_prefix(resolve_symlinks(root))
648 .map(Path::to_path_buf)
649 .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
650 if !relative.starts_with(ticket_dir) {
651 return Err(PostError::OutsideTicketDirectory {
652 path: file.to_owned(),
653 directory: ticket_dir.to_path_buf(),
654 });
655 }
656 Ok(relative)
657}
658
659fn resolve_symlinks(path: &Path) -> PathBuf {
671 let mut unresolved = Vec::new();
672 let mut prefix = path;
673 loop {
674 if let Ok(resolved) = prefix.canonicalize() {
675 return unresolved
676 .iter()
677 .rev()
678 .fold(resolved, |base, component| base.join(component));
679 }
680 match (prefix.parent(), prefix.file_name()) {
681 (Some(parent), Some(name)) => {
682 unresolved.push(name);
683 prefix = parent;
684 }
685 _ => return path.to_path_buf(),
686 }
687 }
688}
689
690#[derive(Debug)]
693pub enum TicketProblem {
694 Frontmatter(FrontmatterError),
695 MissingName,
696 MissingBlockedBy,
697 InvalidBlockedBy,
698 EmptyBody,
699}
700
701impl From<FrontmatterError> for TicketProblem {
702 fn from(error: FrontmatterError) -> Self {
703 match error {
704 FrontmatterError::InvalidBlockedBy => Self::InvalidBlockedBy,
705 error => Self::Frontmatter(error),
706 }
707 }
708}
709
710impl fmt::Display for TicketProblem {
711 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
712 match self {
713 Self::Frontmatter(error) => error.fmt(formatter),
714 Self::MissingName => {
715 formatter.write_str("missing or empty `name`; add `name: Your ticket title`")
716 }
717 Self::MissingBlockedBy => formatter.write_str(
718 "missing `blocked_by`; add `blocked_by: []` if there are no dependencies",
719 ),
720 Self::InvalidBlockedBy => formatter.write_str(
721 "invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs",
722 ),
723 Self::EmptyBody => {
724 formatter.write_str("empty `body`; add a ticket description after the frontmatter")
725 }
726 }
727 }
728}
729
730#[derive(Debug)]
731pub enum PostError {
732 TicketFileNotFound(String),
733 OutsideRepository(String),
734 OutsideTicketDirectory {
735 path: String,
736 directory: PathBuf,
737 },
738 InvalidTicket {
739 path: String,
740 error: FrontmatterError,
741 },
742 InvalidTicketFields {
745 path: String,
746 problems: Vec<TicketProblem>,
747 },
748 InvalidWorktreeStem {
749 path: String,
750 reason: String,
751 },
752 UnknownBlockedBy {
753 ticket: String,
754 blocker: String,
755 named: Option<String>,
759 },
760 DependencyCycle(Vec<String>),
761 UnknownProject(String),
762 UnknownTarget(String),
763 MissingTargetValue {
764 target: String,
765 message: String,
766 },
767 ProjectConflict {
768 path: String,
769 stamped: String,
770 requested: String,
771 },
772 FlowConflict {
773 path: String,
774 stamped: String,
775 requested: String,
776 },
777 UnknownFlow {
778 flow: String,
779 known: Vec<String>,
780 },
781 TicketIdTaken {
782 id: String,
783 file: String,
784 },
785 Io {
786 path: String,
787 source: io::Error,
788 },
789 Source(SourceError),
790 Store(StoreError),
791 IdAllocation(IdError),
792}
793
794impl From<SourceError> for PostError {
795 fn from(error: SourceError) -> Self {
796 Self::Source(error)
797 }
798}
799
800impl From<StoreError> for PostError {
801 fn from(error: StoreError) -> Self {
802 Self::Store(error)
803 }
804}
805
806impl fmt::Display for PostError {
807 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
808 match self {
809 Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
810 Self::OutsideRepository(path) => {
811 write!(formatter, "`{path}` is outside the repository")
812 }
813 Self::OutsideTicketDirectory { path, directory } => write!(
814 formatter,
815 "`{path}` is outside the {} directory",
816 directory.display()
817 ),
818 Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
819 Self::InvalidTicketFields { path, problems } => match problems.as_slice() {
820 [problem] => write!(formatter, "{path}: {problem}"),
821 problems => {
822 write!(formatter, "{path}:")?;
823 for problem in problems {
824 write!(formatter, "\n - {problem}")?;
825 }
826 Ok(())
827 }
828 },
829 Self::InvalidWorktreeStem { path, reason } => {
830 write!(formatter, "{path}: {reason}")
831 }
832 Self::UnknownBlockedBy {
833 ticket,
834 blocker,
835 named,
836 } => {
837 write!(
838 formatter,
839 "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
840 )?;
841 if let Some(id) = named {
842 write!(
843 formatter,
844 "; a ticket named `{blocker}` has id `{id}`, and `blocked_by` takes ids"
845 )?;
846 }
847 Ok(())
848 }
849 Self::DependencyCycle(chain) => write!(
850 formatter,
851 "field `blocked_by` creates a dependency cycle: {}",
852 chain.join(" -> ")
853 ),
854 Self::UnknownProject(project) => {
855 write!(formatter, "project `{project}` is not indexed")
856 }
857 Self::UnknownTarget(target) => {
858 write!(formatter, "agent target `{target}` is not configured")
859 }
860 Self::MissingTargetValue { target, message } => {
861 write!(formatter, "ticket using agent target `{target}` {message}")
862 }
863 Self::ProjectConflict {
864 path,
865 stamped,
866 requested,
867 } => write!(
868 formatter,
869 "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
870 ),
871 Self::FlowConflict {
872 path,
873 stamped,
874 requested,
875 } => write!(
876 formatter,
877 "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
878 ),
879 Self::UnknownFlow { flow, known } => write!(
880 formatter,
881 "flow `{flow}` is not defined; known flows: {}",
882 known.join(", ")
883 ),
884 Self::TicketIdTaken { id, file } => write!(
885 formatter,
886 "ticket ID `{id}` is already registered by `{file}`"
887 ),
888 Self::Io { path, source } => write!(formatter, "{path}: {source}"),
889 Self::Source(error) => error.fmt(formatter),
890 Self::Store(error) => error.fmt(formatter),
891 Self::IdAllocation(error) => error.fmt(formatter),
892 }
893 }
894}
895
896impl std::error::Error for PostError {}
897
898#[cfg(test)]
899mod tests {
900 use std::collections::BTreeMap;
901
902 use tempfile::tempdir;
903
904 use super::{
905 MarkdownWorkStateAuthor, PostError, handle as handle_with_directory, source_version,
906 };
907 use crate::config::{AgentConfig, AgentTarget};
908 use crate::db::Db;
909 use crate::domain::work::{ExecutionHints, TicketRef, WorkTicket, WorkTicketState};
910 use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage};
911 use crate::protocol::{PostArgs, PostTrigger};
912 use crate::work_state::local::LocalSqlite;
913 use crate::work_state::{SourceError, WorkStateAuthor};
914
915 fn world() -> (tempfile::TempDir, LocalSqlite) {
916 let root = tempdir().unwrap();
917 std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
918 let store = LocalSqlite::from_db(Db::open(&root.path().join("sloop.db"), 1_000).unwrap());
919 store
920 .upsert_local_project(
921 "default",
922 ".agents/sloop/projects/default.md",
923 "Default",
924 1_000,
925 )
926 .unwrap();
927 (root, store)
928 }
929
930 #[allow(clippy::too_many_arguments)]
931 fn handle(
932 root: &std::path::Path,
933 store: &LocalSqlite,
934 args: &PostArgs,
935 now_ms: i64,
936 ticket_prefix: &str,
937 agent: Option<&AgentConfig>,
938 flows: &BTreeMap<String, Flow>,
939 default_flow: &str,
940 ) -> Result<serde_json::Value, PostError> {
941 tokio::runtime::Runtime::new()
942 .unwrap()
943 .block_on(handle_with_directory(
944 root,
945 std::path::Path::new(".agents/sloop/tickets"),
946 store,
947 args,
948 now_ms,
949 None,
950 ticket_prefix,
951 agent,
952 flows,
953 default_flow,
954 ))
955 }
956
957 fn handle_at(
958 root: &std::path::Path,
959 store: &LocalSqlite,
960 args: &PostArgs,
961 now_ms: i64,
962 at_eligible_ms: i64,
963 ) -> Result<serde_json::Value, PostError> {
964 tokio::runtime::Runtime::new()
965 .unwrap()
966 .block_on(handle_with_directory(
967 root,
968 std::path::Path::new(".agents/sloop/tickets"),
969 store,
970 args,
971 now_ms,
972 Some(at_eligible_ms),
973 "TICK",
974 None,
975 &flows(),
976 "default",
977 ))
978 }
979
980 fn post(file: &str, trigger: PostTrigger) -> PostArgs {
981 PostArgs {
982 file: file.into(),
983 project: None,
984 flow: None,
985 trigger,
986 }
987 }
988
989 fn flows() -> BTreeMap<String, Flow> {
990 BTreeMap::from([
991 (
992 "default".to_owned(),
993 Flow {
994 name: "default".into(),
995 stages: vec![Stage {
996 name: "build".into(),
997 action: Actor::Agent,
998 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
999 fail_action: FailAction::Halt,
1000 ff_only: false,
1001 }],
1002 },
1003 ),
1004 (
1005 "release".to_owned(),
1006 Flow {
1007 name: "release".into(),
1008 stages: vec![Stage {
1009 name: "build".into(),
1010 action: Actor::Agent,
1011 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
1012 fail_action: FailAction::Halt,
1013 ff_only: false,
1014 }],
1015 },
1016 ),
1017 ])
1018 }
1019
1020 fn ticket(frontmatter: &str, body: &str) -> String {
1021 format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
1022 }
1023
1024 fn agent() -> AgentConfig {
1025 AgentConfig {
1026 default_target: "claude".into(),
1027 targets: BTreeMap::from([
1028 (
1029 "claude".into(),
1030 AgentTarget {
1031 cmd: vec!["claude".into(), "{prompt}".into()],
1032 model: None,
1033 effort: None,
1034 },
1035 ),
1036 (
1037 "codex".into(),
1038 AgentTarget {
1039 cmd: vec![
1040 "codex".into(),
1041 "{model}".into(),
1042 "{effort}".into(),
1043 "{prompt}".into(),
1044 ],
1045 model: None,
1046 effort: None,
1047 },
1048 ),
1049 ]),
1050 }
1051 }
1052
1053 fn settle(store: &LocalSqlite, id: &str, state: &str) {
1057 let changed = store
1058 .db()
1059 .lock()
1060 .execute(
1061 "UPDATE tickets SET state = ?2 WHERE id = ?1",
1062 rusqlite::params![id, state],
1063 )
1064 .unwrap();
1065 assert_eq!(changed, 1);
1066 }
1067
1068 #[test]
1069 fn reposting_a_settled_ticket_refreshes_content_without_queuing_an_trigger() {
1070 for state in ["merged", "failed", "needs_review"] {
1071 let (root, store) = world();
1072 let relative = ".agents/sloop/tickets/settled.md";
1073 let path = root.path().join(relative);
1074 std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1075 handle(
1076 root.path(),
1077 &store,
1078 &post(relative, PostTrigger::Manual),
1079 2_000,
1080 "TICK",
1081 None,
1082 &flows(),
1083 "default",
1084 )
1085 .unwrap();
1086 settle(&store, "TICK-1", state);
1087 let stamped = std::fs::read_to_string(&path).unwrap();
1088 std::fs::write(&path, stamped.replace("# Original", "# Edited")).unwrap();
1089
1090 let response = handle(
1091 root.path(),
1092 &store,
1093 &post(relative, PostTrigger::Auto),
1094 3_000,
1095 "TICK",
1096 None,
1097 &flows(),
1098 "default",
1099 )
1100 .unwrap();
1101
1102 assert_eq!(response["ticket"]["id"], "TICK-1");
1103 assert_eq!(response["created"], false);
1104 assert_eq!(
1105 response["ticket"]["state"], state,
1106 "a repost must not resurrect a {state} ticket"
1107 );
1108 assert!(
1109 store
1110 .ticket("TICK-1")
1111 .unwrap()
1112 .unwrap()
1113 .body
1114 .unwrap()
1115 .contains("# Edited")
1116 );
1117 assert!(response["trigger"].is_null());
1118 assert_eq!(
1119 response["trigger_suppressed"],
1120 serde_json::json!({"reason": "terminal_ticket", "state": state})
1121 );
1122 assert!(store.queued_triggers().unwrap().is_empty());
1123 }
1124 }
1125
1126 #[test]
1127 fn reposting_a_settled_ticket_with_at_neither_creates_nor_reschedules_an_trigger() {
1128 let (root, store) = world();
1129 let relative = ".agents/sloop/tickets/timed.md";
1130 std::fs::write(root.path().join(relative), ticket("", "# Timed\n")).unwrap();
1131 let args = post(
1132 relative,
1133 PostTrigger::At {
1134 time: "03:00".into(),
1135 },
1136 );
1137 let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1138 assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1139 settle(&store, "TICK-1", "merged");
1140
1141 let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1142
1143 assert!(second["trigger"].is_null());
1144 assert_eq!(second["trigger_suppressed"]["state"], "merged");
1145 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 =
1615 LocalSqlite::from_db(Db::open(&root.path().join("fresh.db"), 3_000).unwrap());
1616 fresh_store
1617 .upsert_local_project(
1618 "default",
1619 ".agents/sloop/projects/default.md",
1620 "Default",
1621 3_000,
1622 )
1623 .unwrap();
1624 let response = handle(
1625 root.path(),
1626 &fresh_store,
1627 &args,
1628 3_000,
1629 "TICK",
1630 None,
1631 &flows(),
1632 "default",
1633 )
1634 .unwrap();
1635
1636 assert_eq!(response["ticket"]["id"], "TICK-1");
1637 assert_eq!(response["ticket"]["flow"], "default");
1638 }
1639
1640 #[test]
1641 fn idless_tickets_get_monotonic_generated_ids() {
1642 let (root, store) = world();
1643 std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1644 std::fs::write(
1645 root.path().join(".agents/sloop/tickets/fix.md"),
1646 ticket("", "# A\n"),
1647 )
1648 .unwrap();
1649 std::fs::write(
1650 root.path().join(".agents/sloop/tickets/nested/fix.md"),
1651 ticket("", "# B\n"),
1652 )
1653 .unwrap();
1654
1655 let first = handle(
1656 root.path(),
1657 &store,
1658 &post(".agents/sloop/tickets/fix.md", PostTrigger::Manual),
1659 2_000,
1660 "TICK",
1661 None,
1662 &flows(),
1663 "default",
1664 )
1665 .unwrap();
1666 let second = handle(
1667 root.path(),
1668 &store,
1669 &post(".agents/sloop/tickets/nested/fix.md", PostTrigger::Manual),
1670 2_100,
1671 "TICK",
1672 None,
1673 &flows(),
1674 "default",
1675 )
1676 .unwrap();
1677 assert_eq!(first["ticket"]["id"], "TICK-1");
1678 assert_eq!(second["ticket"]["id"], "TICK-2");
1679 }
1680
1681 #[test]
1682 fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1683 let (root, store) = world();
1684 let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1685 let explicit_content = ticket(
1686 "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1687 "# Explicit\n",
1688 );
1689 std::fs::write(&explicit, &explicit_content).unwrap();
1690 handle(
1691 root.path(),
1692 &store,
1693 &post(".agents/sloop/tickets/explicit.md", PostTrigger::Manual),
1694 2_000,
1695 "WORK",
1696 None,
1697 &flows(),
1698 "default",
1699 )
1700 .unwrap();
1701 assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1702
1703 std::fs::write(
1704 root.path().join(".agents/sloop/tickets/unrelated.md"),
1705 ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1706 )
1707 .unwrap();
1708 handle(
1709 root.path(),
1710 &store,
1711 &post(".agents/sloop/tickets/unrelated.md", PostTrigger::Manual),
1712 2_100,
1713 "WORK",
1714 None,
1715 &flows(),
1716 "default",
1717 )
1718 .unwrap();
1719
1720 std::fs::write(
1721 root.path().join(".agents/sloop/tickets/generated.md"),
1722 ticket("", "# Generated\n"),
1723 )
1724 .unwrap();
1725 let generated = handle(
1726 root.path(),
1727 &store,
1728 &post(".agents/sloop/tickets/generated.md", PostTrigger::Manual),
1729 2_200,
1730 "WORK",
1731 None,
1732 &flows(),
1733 "default",
1734 )
1735 .unwrap();
1736 assert_eq!(generated["ticket"]["id"], "WORK-10");
1737 }
1738
1739 #[test]
1740 fn paths_escaping_the_repository_are_rejected() {
1741 let (root, store) = world();
1742 let args = post("../outside.md", PostTrigger::Manual);
1743
1744 assert!(matches!(
1745 handle(
1746 root.path(),
1747 &store,
1748 &args,
1749 2_000,
1750 "TICK",
1751 None,
1752 &flows(),
1753 "default"
1754 ),
1755 Err(PostError::OutsideRepository(_))
1756 ));
1757 }
1758
1759 #[test]
1764 fn absolute_paths_through_a_symlinked_root_stay_inside_the_repository() {
1765 let (root, store) = world();
1766 let link = tempdir().unwrap();
1767 let linked_root = link.path().join("repository");
1768 std::os::unix::fs::symlink(root.path(), &linked_root).unwrap();
1769
1770 let ticket = linked_root.join(".agents/sloop/tickets/linked.md");
1771 std::fs::write(&ticket, "---\nname: Linked\nblocked_by: []\n---\n\nBody\n").unwrap();
1772
1773 let posted = handle(
1774 root.path(),
1775 &store,
1776 &post(ticket.to_str().unwrap(), PostTrigger::Manual),
1777 2_000,
1778 "TICK",
1779 None,
1780 &flows(),
1781 "default",
1782 )
1783 .unwrap();
1784 assert_eq!(posted["ticket"]["name"], "Linked");
1785 }
1786
1787 #[test]
1791 fn ticket_files_symlinked_out_of_the_repository_are_rejected() {
1792 let (root, store) = world();
1793 let outside = tempdir().unwrap();
1794 let target = outside.path().join("elsewhere.md");
1795 std::fs::write(
1796 &target,
1797 "---\nname: Elsewhere\nblocked_by: []\n---\n\nBody\n",
1798 )
1799 .unwrap();
1800 std::os::unix::fs::symlink(&target, root.path().join(".agents/sloop/tickets/escape.md"))
1801 .unwrap();
1802
1803 assert!(matches!(
1804 handle(
1805 root.path(),
1806 &store,
1807 &post(".agents/sloop/tickets/escape.md", PostTrigger::Manual),
1808 2_000,
1809 "TICK",
1810 None,
1811 &flows(),
1812 "default",
1813 ),
1814 Err(PostError::OutsideRepository(_))
1815 ));
1816 }
1817
1818 #[test]
1821 fn missing_ticket_files_inside_the_directory_still_report_not_found() {
1822 let (root, store) = world();
1823
1824 assert!(matches!(
1825 handle(
1826 root.path(),
1827 &store,
1828 &post(".agents/sloop/tickets/absent.md", PostTrigger::Manual),
1829 2_000,
1830 "TICK",
1831 None,
1832 &flows(),
1833 "default",
1834 ),
1835 Err(PostError::TicketFileNotFound(_))
1836 ));
1837 }
1838
1839 #[test]
1840 fn paths_outside_the_ticket_directory_are_rejected() {
1841 let (root, store) = world();
1842 std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1843
1844 assert!(matches!(
1845 handle(
1846 root.path(),
1847 &store,
1848 &post("elsewhere.md", PostTrigger::Manual),
1849 2_000,
1850 "TICK",
1851 None,
1852 &flows(),
1853 "default",
1854 ),
1855 Err(PostError::OutsideTicketDirectory { .. })
1856 ));
1857 }
1858}