Skip to main content

sloop/
post.rs

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
88/// Local markdown authoring over SQLite.
89///
90/// A [`SourceVersion`] is the lowercase hexadecimal FNV-1a hash of the
91/// complete markdown file content. Updates compare that version with the file
92/// immediately before committing, so a concurrent edit is rejected instead
93/// of silently overwritten.
94struct 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/// Registers a ticket file: validates and stamps frontmatter, indexes the
227/// ticket, and for `auto` and `at` creates one queued trigger. Reposting
228/// a stamped file is idempotent; reposting with a different `--at` time
229/// reschedules the queued trigger. The dispatcher is the only caller and
230/// computes `at_eligible_ms` from its injected clock, so plain reads before
231/// writes here cannot race another writer.
232///
233/// A trigger is queued only when the post leaves the ticket in `ready`.
234/// Reposting a settled ticket still refreshes the indexed content — editing
235/// a merged ticket's file must keep working — but queues nothing.
236#[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    // A repost cannot move a settled ticket: `update_authored_ticket` omits
399    // `state` from its `SET`, so `merged`, `failed`, and `needs_review` all
400    // survive the write. Dispatch requires `ready`, so a trigger queued
401    // here could never fire; it would only sit in `queued_triggers` as
402    // phantom demand and skew every gate that reads that count. `failed` is
403    // included even though `sloop retry` revives it: a lingering trigger
404    // would make one failed ticket spawn on `retry` while every other one
405    // waits for `sloop run`.
406    let terminal_state = existing
407        .as_ref()
408        .map(|ticket| ticket.state.clone())
409        .filter(|state| matches!(state.as_str(), "merged" | "failed" | "needs_review"));
410    let trigger_request = if terminal_state.is_some() {
411        None
412    } else {
413        match &args.trigger {
414            PostTrigger::Manual | PostTrigger::Hold => None,
415            PostTrigger::Auto => Some(TriggerRequest {
416                kind: TriggerKind::Auto,
417                eligible_at_ms: None,
418            }),
419            PostTrigger::At { .. } => Some(TriggerRequest {
420                kind: TriggerKind::At,
421                eligible_at_ms: Some(
422                    at_eligible_ms.expect("the dispatcher computes eligibility for at triggers"),
423                ),
424            }),
425        }
426    };
427    let work_ticket = WorkTicket {
428        id: ticket_id.clone(),
429        project_id: project.clone(),
430        name: stamped.name.clone(),
431        body: frontmatter::body(&content)
432            .expect("validated frontmatter has a body")
433            .to_owned(),
434        state: WorkTicketState::from_ticket_state(
435            initial_state,
436            false,
437            String::new(),
438            crate::domain::work::OwnerId(String::new()),
439        ),
440        blocked_by: stamped.blocked_by.clone(),
441        attempts: existing.as_ref().map_or(0, |ticket| ticket.attempts as u32),
442        hints: ExecutionHints {
443            worktree: Some(worktree.clone()),
444            trigger_id: None,
445            target,
446            model: stamped.model.clone(),
447            effort: stamped.effort.clone(),
448            flow: Some(flow_name.clone()),
449        },
450        version: source_version(&final_content),
451    };
452    let author = MarkdownWorkStateAuthor {
453        root,
454        file_path: &relative_str,
455        worktree: &worktree,
456        work_state,
457        original_content: &content,
458        final_content: &final_content,
459        original_version: source_version(&content),
460        trigger: trigger_request,
461        now_ms,
462        trigger_result: Mutex::new(Value::Null),
463    };
464    let ticket_ref = TicketRef {
465        id: ticket_id,
466        source: "local".into(),
467        source_ref: Some(relative_str.clone()),
468    };
469    let created = existing.is_none();
470    if created {
471        author.post(&work_ticket).await?;
472    } else {
473        author
474            .update(&ticket_ref, &work_ticket, &author.original_version)
475            .await?;
476    }
477    let trigger = author.trigger_result();
478    let ticket = work_state
479        .ticket(&work_ticket.id)?
480        .expect("registered ticket still exists");
481
482    Ok(json!({
483        "ticket": {
484            "id": ticket.id,
485            "project": project,
486            "file": relative_str,
487            "state": ticket.state,
488            "name": ticket.name,
489            "blocked_by": ticket.blocked_by,
490            "worktree": ticket.worktree,
491            "target": ticket.target,
492            "model": ticket.model,
493            "effort": ticket.effort,
494            "flow": ticket.flow,
495        },
496        "created": created,
497        "trigger": trigger,
498        // `trigger` alone cannot tell a machine consumer why it is null:
499        // `--manual` and `--hold` never asked for one, while a terminal
500        // ticket asked and was refused. Only the second sets this.
501        "trigger_suppressed": terminal_state.map(|state| json!({
502            "reason": "terminal_ticket",
503            "state": state,
504        })),
505    }))
506}
507
508/// Validates a ticket file, reporting *every* independent problem at once so
509/// authoring a ticket does not turn into one round-trip per mistake.
510///
511/// The split between short-circuiting and accumulating is deliberate. A file
512/// whose frontmatter cannot be read at all — no block, unterminated, YAML
513/// that does not parse, a block that is not a mapping — fails fast: no field
514/// can be read out of it, so every other check would either be unanswerable
515/// or degenerate into "everything is missing". Once a mapping is in hand,
516/// each field and the body are independent, and the caller deserves the full
517/// list. Checks that need the store (unknown blockers, dependency cycles,
518/// project/flow/target resolution) stay in `handle`: they are registration
519/// problems rather than problems with the file, and they carry their own
520/// error codes.
521pub(crate) fn parse_ticket_frontmatter(
522    content: &str,
523    path: &str,
524) -> Result<frontmatter::Frontmatter, PostError> {
525    let (stamped, field_errors) =
526        frontmatter::parse_collecting(content).map_err(|error| PostError::InvalidTicket {
527            path: path.to_owned(),
528            error,
529        })?;
530
531    // A field that failed to parse is already reported by its own problem;
532    // adding "missing" on top of "wrong type" would only muddy the list.
533    let name_is_reported = field_errors
534        .iter()
535        .any(|error| matches!(error, FrontmatterError::InvalidFieldType { key } if key == "name"));
536    let blocked_by_is_reported = field_errors
537        .iter()
538        .any(|error| matches!(error, FrontmatterError::InvalidBlockedBy));
539
540    let mut problems = Vec::new();
541    if !name_is_reported && stamped.name.trim().is_empty() {
542        problems.push(TicketProblem::MissingName);
543    }
544    if !blocked_by_is_reported && !stamped.has_blocked_by() {
545        problems.push(TicketProblem::MissingBlockedBy);
546    }
547    if frontmatter::body(content)
548        .expect("frontmatter was already parsed")
549        .trim()
550        .is_empty()
551    {
552        problems.push(TicketProblem::EmptyBody);
553    }
554    problems.extend(field_errors.into_iter().map(TicketProblem::from));
555
556    if problems.is_empty() {
557        Ok(stamped)
558    } else {
559        Err(PostError::InvalidTicketFields {
560            path: path.to_owned(),
561            problems,
562        })
563    }
564}
565
566/// Queues the demand a post asked for, inside the transaction that registers
567/// the ticket. `Duplicates::Reuse` is what makes reposting idempotent: an
568/// existing queued trigger of the same kind absorbs the request instead of
569/// piling a second one behind it.
570///
571/// `request` is `None` for a settled ticket, which is what keeps the reuse
572/// branch from re-timing a stale `--at` trigger onto one: the caller decides
573/// eligibility, this function only carries it out.
574fn queue_trigger_transaction(
575    transaction: &Transaction<'_>,
576    ticket_id: &str,
577    request: Option<TriggerRequest>,
578    now_ms: i64,
579) -> Result<Value, StoreError> {
580    let Some(request) = request else {
581        return Ok(Value::Null);
582    };
583    let id = trigger::enqueue(
584        transaction,
585        &EnqueueRequest {
586            kind: request.kind,
587            ticket_id: Some(ticket_id),
588            project_id: None,
589            eligible_at_ms: request.eligible_at_ms,
590            interval_ms: None,
591            filters: &[],
592            duplicates: Duplicates::Reuse,
593        },
594        now_ms,
595    )?
596    .id;
597    let mut trigger = json!({
598        "id": id,
599        "kind": request.kind.as_str(),
600        "state": "queued",
601        "ticket": ticket_id,
602    });
603    if let Some(eligible_at_ms) = request.eligible_at_ms {
604        trigger["eligible_at_ms"] = json!(eligible_at_ms);
605    }
606    Ok(trigger)
607}
608
609fn source_store_error(error: StoreError) -> SourceError {
610    if error.is_disk_full() {
611        SourceError::Unavailable { retry_after: None }
612    } else {
613        SourceError::Corrupt {
614            message: error.to_string(),
615        }
616    }
617}
618
619fn source_version(content: &str) -> SourceVersion {
620    let mut hash = 0xcbf29ce484222325_u64;
621    for byte in content.as_bytes() {
622        hash ^= u64::from(*byte);
623        hash = hash.wrapping_mul(0x100000001b3);
624    }
625    SourceVersion(format!("{hash:016x}"))
626}
627
628fn allocate_ticket_id(work_state: &LocalSqlite, prefix: &str) -> Result<String, PostError> {
629    let ids = work_state.ticket_ids()?;
630    next_id(prefix, ids.iter().map(String::as_str)).map_err(PostError::IdAllocation)
631}
632
633/// Resolves the request path against the repository root and requires the
634/// result to stay inside the committed Sloop ticket directory.
635fn repository_relative(root: &Path, ticket_dir: &Path, file: &str) -> Result<PathBuf, PostError> {
636    let path = Path::new(file);
637    let joined = if path.is_absolute() {
638        path.to_path_buf()
639    } else {
640        root.join(path)
641    };
642
643    let mut normalized = PathBuf::new();
644    for component in joined.components() {
645        match component {
646            Component::CurDir => {}
647            Component::ParentDir => {
648                if !normalized.pop() {
649                    return Err(PostError::OutsideRepository(file.to_owned()));
650                }
651            }
652            component => normalized.push(component),
653        }
654    }
655    // Both sides go through the same resolution: `Repository::discover` hands
656    // this a canonical root, but a caller that does not must not silently get
657    // a containment answer decided by symlink spelling.
658    let relative = resolve_symlinks(&normalized)
659        .strip_prefix(resolve_symlinks(root))
660        .map(Path::to_path_buf)
661        .map_err(|_| PostError::OutsideRepository(file.to_owned()))?;
662    if !relative.starts_with(ticket_dir) {
663        return Err(PostError::OutsideTicketDirectory {
664            path: file.to_owned(),
665            directory: ticket_dir.to_path_buf(),
666        });
667    }
668    Ok(relative)
669}
670
671/// Resolves `path` through any symlinks along it, so containment is decided on
672/// the same footing as the repository root, which `Repository::discover`
673/// always canonicalizes. Without this, a root reached through a symlink — the
674/// normal case on macOS, where `/tmp` and `/var/folders` are both links into
675/// `/private` — makes every absolute path the operator types look external.
676///
677/// Containment is checked before the ticket file is read, so the path need not
678/// exist yet. The longest existing ancestor is canonicalized and the remaining
679/// components are appended unresolved, which keeps a missing file inside the
680/// ticket directory reported as `not found` rather than as an escape. A path
681/// with no resolvable ancestor keeps its lexical form for the same reason.
682fn resolve_symlinks(path: &Path) -> PathBuf {
683    let mut unresolved = Vec::new();
684    let mut prefix = path;
685    loop {
686        if let Ok(resolved) = prefix.canonicalize() {
687            return unresolved
688                .iter()
689                .rev()
690                .fold(resolved, |base, component| base.join(component));
691        }
692        match (prefix.parent(), prefix.file_name()) {
693            (Some(parent), Some(name)) => {
694                unresolved.push(name);
695                prefix = parent;
696            }
697            _ => return path.to_path_buf(),
698        }
699    }
700}
701
702/// A single problem with a ticket file, phrased without the file path so
703/// several can be listed under one path heading.
704#[derive(Debug)]
705pub enum TicketProblem {
706    Frontmatter(FrontmatterError),
707    MissingName,
708    MissingBlockedBy,
709    InvalidBlockedBy,
710    EmptyBody,
711}
712
713impl From<FrontmatterError> for TicketProblem {
714    fn from(error: FrontmatterError) -> Self {
715        match error {
716            FrontmatterError::InvalidBlockedBy => Self::InvalidBlockedBy,
717            error => Self::Frontmatter(error),
718        }
719    }
720}
721
722impl fmt::Display for TicketProblem {
723    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
724        match self {
725            Self::Frontmatter(error) => error.fmt(formatter),
726            Self::MissingName => {
727                formatter.write_str("missing or empty `name`; add `name: Your ticket title`")
728            }
729            Self::MissingBlockedBy => formatter.write_str(
730                "missing `blocked_by`; add `blocked_by: []` if there are no dependencies",
731            ),
732            Self::InvalidBlockedBy => formatter.write_str(
733                "invalid `blocked_by`; use `blocked_by: []` or a YAML list of ticket IDs",
734            ),
735            Self::EmptyBody => {
736                formatter.write_str("empty `body`; add a ticket description after the frontmatter")
737            }
738        }
739    }
740}
741
742#[derive(Debug)]
743pub enum PostError {
744    TicketFileNotFound(String),
745    OutsideRepository(String),
746    OutsideTicketDirectory {
747        path: String,
748        directory: PathBuf,
749    },
750    InvalidTicket {
751        path: String,
752        error: FrontmatterError,
753    },
754    /// One or more independent problems with the ticket file itself,
755    /// reported together. Never empty.
756    InvalidTicketFields {
757        path: String,
758        problems: Vec<TicketProblem>,
759    },
760    InvalidWorktreeStem {
761        path: String,
762        reason: String,
763    },
764    UnknownBlockedBy {
765        ticket: String,
766        blocker: String,
767    },
768    DependencyCycle(Vec<String>),
769    UnknownProject(String),
770    UnknownTarget(String),
771    MissingTargetValue {
772        target: String,
773        message: String,
774    },
775    ProjectConflict {
776        path: String,
777        stamped: String,
778        requested: String,
779    },
780    FlowConflict {
781        path: String,
782        stamped: String,
783        requested: String,
784    },
785    UnknownFlow {
786        flow: String,
787        known: Vec<String>,
788    },
789    TicketIdTaken {
790        id: String,
791        file: String,
792    },
793    Io {
794        path: String,
795        source: io::Error,
796    },
797    Source(SourceError),
798    Store(StoreError),
799    IdAllocation(IdError),
800}
801
802impl From<SourceError> for PostError {
803    fn from(error: SourceError) -> Self {
804        Self::Source(error)
805    }
806}
807
808impl From<StoreError> for PostError {
809    fn from(error: StoreError) -> Self {
810        Self::Store(error)
811    }
812}
813
814impl fmt::Display for PostError {
815    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
816        match self {
817            Self::TicketFileNotFound(path) => write!(formatter, "ticket file `{path}` not found"),
818            Self::OutsideRepository(path) => {
819                write!(formatter, "`{path}` is outside the repository")
820            }
821            Self::OutsideTicketDirectory { path, directory } => write!(
822                formatter,
823                "`{path}` is outside the {} directory",
824                directory.display()
825            ),
826            Self::InvalidTicket { path, error } => write!(formatter, "{path}: {error}"),
827            // A lone problem keeps the original one-line `path: problem`
828            // shape; only a genuine list needs the heading and bullets.
829            Self::InvalidTicketFields { path, problems } => match problems.as_slice() {
830                [problem] => write!(formatter, "{path}: {problem}"),
831                problems => {
832                    write!(formatter, "{path}:")?;
833                    for problem in problems {
834                        write!(formatter, "\n  - {problem}")?;
835                    }
836                    Ok(())
837                }
838            },
839            Self::InvalidWorktreeStem { path, reason } => {
840                write!(formatter, "{path}: {reason}")
841            }
842            Self::UnknownBlockedBy { ticket, blocker } => write!(
843                formatter,
844                "ticket `{ticket}` field `blocked_by` references unknown ticket `{blocker}`"
845            ),
846            Self::DependencyCycle(chain) => write!(
847                formatter,
848                "field `blocked_by` creates a dependency cycle: {}",
849                chain.join(" -> ")
850            ),
851            Self::UnknownProject(project) => {
852                write!(formatter, "project `{project}` is not indexed")
853            }
854            Self::UnknownTarget(target) => {
855                write!(formatter, "agent target `{target}` is not configured")
856            }
857            Self::MissingTargetValue { target, message } => {
858                write!(formatter, "ticket using agent target `{target}` {message}")
859            }
860            Self::ProjectConflict {
861                path,
862                stamped,
863                requested,
864            } => write!(
865                formatter,
866                "{path}: ticket belongs to project `{stamped}`, not `{requested}`"
867            ),
868            Self::FlowConflict {
869                path,
870                stamped,
871                requested,
872            } => write!(
873                formatter,
874                "{path}: ticket is bound to flow `{stamped}`, not `{requested}`"
875            ),
876            Self::UnknownFlow { flow, known } => write!(
877                formatter,
878                "flow `{flow}` is not defined; known flows: {}",
879                known.join(", ")
880            ),
881            Self::TicketIdTaken { id, file } => write!(
882                formatter,
883                "ticket ID `{id}` is already registered by `{file}`"
884            ),
885            Self::Io { path, source } => write!(formatter, "{path}: {source}"),
886            Self::Source(error) => error.fmt(formatter),
887            Self::Store(error) => error.fmt(formatter),
888            Self::IdAllocation(error) => error.fmt(formatter),
889        }
890    }
891}
892
893impl std::error::Error for PostError {}
894
895#[cfg(test)]
896mod tests {
897    use std::collections::BTreeMap;
898
899    use tempfile::tempdir;
900
901    use super::{
902        MarkdownWorkStateAuthor, PostError, handle as handle_with_directory, source_version,
903    };
904    use crate::config::{AgentConfig, AgentTarget};
905    use crate::db::Db;
906    use crate::domain::work::{ExecutionHints, TicketRef, WorkTicket, WorkTicketState};
907    use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage};
908    use crate::protocol::{PostArgs, PostTrigger};
909    use crate::work_state::local::LocalSqlite;
910    use crate::work_state::{SourceError, WorkStateAuthor};
911
912    fn world() -> (tempfile::TempDir, LocalSqlite) {
913        let root = tempdir().unwrap();
914        std::fs::create_dir_all(root.path().join(".agents/sloop/tickets")).unwrap();
915        let store = LocalSqlite::from_db(Db::open(&root.path().join("sloop.db"), 1_000).unwrap());
916        store
917            .upsert_local_project(
918                "default",
919                ".agents/sloop/projects/default.md",
920                "Default",
921                1_000,
922            )
923            .unwrap();
924        (root, store)
925    }
926
927    #[allow(clippy::too_many_arguments)]
928    fn handle(
929        root: &std::path::Path,
930        store: &LocalSqlite,
931        args: &PostArgs,
932        now_ms: i64,
933        ticket_prefix: &str,
934        agent: Option<&AgentConfig>,
935        flows: &BTreeMap<String, Flow>,
936        default_flow: &str,
937    ) -> Result<serde_json::Value, PostError> {
938        tokio::runtime::Runtime::new()
939            .unwrap()
940            .block_on(handle_with_directory(
941                root,
942                std::path::Path::new(".agents/sloop/tickets"),
943                store,
944                args,
945                now_ms,
946                None,
947                ticket_prefix,
948                agent,
949                flows,
950                default_flow,
951            ))
952    }
953
954    fn handle_at(
955        root: &std::path::Path,
956        store: &LocalSqlite,
957        args: &PostArgs,
958        now_ms: i64,
959        at_eligible_ms: i64,
960    ) -> Result<serde_json::Value, PostError> {
961        tokio::runtime::Runtime::new()
962            .unwrap()
963            .block_on(handle_with_directory(
964                root,
965                std::path::Path::new(".agents/sloop/tickets"),
966                store,
967                args,
968                now_ms,
969                Some(at_eligible_ms),
970                "TICK",
971                None,
972                &flows(),
973                "default",
974            ))
975    }
976
977    fn post(file: &str, trigger: PostTrigger) -> PostArgs {
978        PostArgs {
979            file: file.into(),
980            project: None,
981            flow: None,
982            trigger,
983        }
984    }
985
986    fn flows() -> BTreeMap<String, Flow> {
987        BTreeMap::from([
988            (
989                "default".to_owned(),
990                Flow {
991                    name: "default".into(),
992                    stages: vec![Stage {
993                        name: "build".into(),
994                        action: Actor::Agent,
995                        result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
996                        fail_action: FailAction::Halt,
997                        ff_only: false,
998                    }],
999                },
1000            ),
1001            (
1002                "release".to_owned(),
1003                Flow {
1004                    name: "release".into(),
1005                    stages: vec![Stage {
1006                        name: "build".into(),
1007                        action: Actor::Agent,
1008                        result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
1009                        fail_action: FailAction::Halt,
1010                        ff_only: false,
1011                    }],
1012                },
1013            ),
1014        ])
1015    }
1016
1017    fn ticket(frontmatter: &str, body: &str) -> String {
1018        format!("---\nname: Test ticket\nblocked_by: []\n{frontmatter}---\n{body}")
1019    }
1020
1021    fn agent() -> AgentConfig {
1022        AgentConfig {
1023            default_target: "claude".into(),
1024            targets: BTreeMap::from([
1025                (
1026                    "claude".into(),
1027                    AgentTarget {
1028                        cmd: vec!["claude".into(), "{prompt}".into()],
1029                        model: None,
1030                        effort: None,
1031                    },
1032                ),
1033                (
1034                    "codex".into(),
1035                    AgentTarget {
1036                        cmd: vec![
1037                            "codex".into(),
1038                            "{model}".into(),
1039                            "{effort}".into(),
1040                            "{prompt}".into(),
1041                        ],
1042                        model: None,
1043                        effort: None,
1044                    },
1045                ),
1046            ]),
1047        }
1048    }
1049
1050    /// Drops a ticket into a state a post cannot leave, the way a settled run
1051    /// would. Going through the run machinery here would say nothing extra
1052    /// about `post`.
1053    fn settle(store: &LocalSqlite, id: &str, state: &str) {
1054        let changed = store
1055            .db()
1056            .lock()
1057            .execute(
1058                "UPDATE tickets SET state = ?2 WHERE id = ?1",
1059                rusqlite::params![id, state],
1060            )
1061            .unwrap();
1062        assert_eq!(changed, 1);
1063    }
1064
1065    #[test]
1066    fn reposting_a_settled_ticket_refreshes_content_without_queuing_an_trigger() {
1067        for state in ["merged", "failed", "needs_review"] {
1068            let (root, store) = world();
1069            let relative = ".agents/sloop/tickets/settled.md";
1070            let path = root.path().join(relative);
1071            std::fs::write(&path, ticket("", "# Original\n")).unwrap();
1072            handle(
1073                root.path(),
1074                &store,
1075                &post(relative, PostTrigger::Manual),
1076                2_000,
1077                "TICK",
1078                None,
1079                &flows(),
1080                "default",
1081            )
1082            .unwrap();
1083            settle(&store, "TICK-1", state);
1084            let stamped = std::fs::read_to_string(&path).unwrap();
1085            std::fs::write(&path, stamped.replace("# Original", "# Edited")).unwrap();
1086
1087            let response = handle(
1088                root.path(),
1089                &store,
1090                &post(relative, PostTrigger::Auto),
1091                3_000,
1092                "TICK",
1093                None,
1094                &flows(),
1095                "default",
1096            )
1097            .unwrap();
1098
1099            // The edit lands; only the trigger is withheld.
1100            assert_eq!(response["ticket"]["id"], "TICK-1");
1101            assert_eq!(response["created"], false);
1102            assert_eq!(
1103                response["ticket"]["state"], state,
1104                "a repost must not resurrect a {state} ticket"
1105            );
1106            assert!(
1107                store
1108                    .ticket("TICK-1")
1109                    .unwrap()
1110                    .unwrap()
1111                    .body
1112                    .unwrap()
1113                    .contains("# Edited")
1114            );
1115            assert!(response["trigger"].is_null());
1116            assert_eq!(
1117                response["trigger_suppressed"],
1118                serde_json::json!({"reason": "terminal_ticket", "state": state})
1119            );
1120            assert!(store.queued_triggers().unwrap().is_empty());
1121        }
1122    }
1123
1124    #[test]
1125    fn reposting_a_settled_ticket_with_at_neither_creates_nor_reschedules_an_trigger() {
1126        let (root, store) = world();
1127        let relative = ".agents/sloop/tickets/timed.md";
1128        std::fs::write(root.path().join(relative), ticket("", "# Timed\n")).unwrap();
1129        let args = post(
1130            relative,
1131            PostTrigger::At {
1132                time: "03:00".into(),
1133            },
1134        );
1135        let first = handle_at(root.path(), &store, &args, 2_000, 10_000).unwrap();
1136        assert_eq!(first["trigger"]["eligible_at_ms"], 10_000);
1137        settle(&store, "TICK-1", "merged");
1138
1139        let second = handle_at(root.path(), &store, &args, 3_000, 20_000).unwrap();
1140
1141        assert!(second["trigger"].is_null());
1142        assert_eq!(second["trigger_suppressed"]["state"], "merged");
1143        // The trigger left over from before the merge keeps its original
1144        // time: the reschedule branch must not run for a settled ticket.
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        // A fresh store with no rows of its own must recover the flow binding
1615        // purely from the committed frontmatter that the first post stamped.
1616        let fresh_store =
1617            LocalSqlite::from_db(Db::open(&root.path().join("fresh.db"), 3_000).unwrap());
1618        fresh_store
1619            .upsert_local_project(
1620                "default",
1621                ".agents/sloop/projects/default.md",
1622                "Default",
1623                3_000,
1624            )
1625            .unwrap();
1626        let response = handle(
1627            root.path(),
1628            &fresh_store,
1629            &args,
1630            3_000,
1631            "TICK",
1632            None,
1633            &flows(),
1634            "default",
1635        )
1636        .unwrap();
1637
1638        assert_eq!(response["ticket"]["id"], "TICK-1");
1639        assert_eq!(response["ticket"]["flow"], "default");
1640    }
1641
1642    #[test]
1643    fn idless_tickets_get_monotonic_generated_ids() {
1644        let (root, store) = world();
1645        std::fs::create_dir(root.path().join(".agents/sloop/tickets/nested")).unwrap();
1646        std::fs::write(
1647            root.path().join(".agents/sloop/tickets/fix.md"),
1648            ticket("", "# A\n"),
1649        )
1650        .unwrap();
1651        std::fs::write(
1652            root.path().join(".agents/sloop/tickets/nested/fix.md"),
1653            ticket("", "# B\n"),
1654        )
1655        .unwrap();
1656
1657        let first = handle(
1658            root.path(),
1659            &store,
1660            &post(".agents/sloop/tickets/fix.md", PostTrigger::Manual),
1661            2_000,
1662            "TICK",
1663            None,
1664            &flows(),
1665            "default",
1666        )
1667        .unwrap();
1668        let second = handle(
1669            root.path(),
1670            &store,
1671            &post(".agents/sloop/tickets/nested/fix.md", PostTrigger::Manual),
1672            2_100,
1673            "TICK",
1674            None,
1675            &flows(),
1676            "default",
1677        )
1678        .unwrap();
1679        assert_eq!(first["ticket"]["id"], "TICK-1");
1680        assert_eq!(second["ticket"]["id"], "TICK-2");
1681    }
1682
1683    #[test]
1684    fn configured_prefix_and_explicit_high_water_mark_control_allocation() {
1685        let (root, store) = world();
1686        let explicit = root.path().join(".agents/sloop/tickets/explicit.md");
1687        let explicit_content = ticket(
1688            "id: WORK-9\nproject: default\nworktree: custom/work\nflow: default\n",
1689            "# Explicit\n",
1690        );
1691        std::fs::write(&explicit, &explicit_content).unwrap();
1692        handle(
1693            root.path(),
1694            &store,
1695            &post(".agents/sloop/tickets/explicit.md", PostTrigger::Manual),
1696            2_000,
1697            "WORK",
1698            None,
1699            &flows(),
1700            "default",
1701        )
1702        .unwrap();
1703        assert_eq!(std::fs::read_to_string(explicit).unwrap(), explicit_content);
1704
1705        std::fs::write(
1706            root.path().join(".agents/sloop/tickets/unrelated.md"),
1707            ticket("id: OTHER-100\nproject: default\n", "# Unrelated\n"),
1708        )
1709        .unwrap();
1710        handle(
1711            root.path(),
1712            &store,
1713            &post(".agents/sloop/tickets/unrelated.md", PostTrigger::Manual),
1714            2_100,
1715            "WORK",
1716            None,
1717            &flows(),
1718            "default",
1719        )
1720        .unwrap();
1721
1722        std::fs::write(
1723            root.path().join(".agents/sloop/tickets/generated.md"),
1724            ticket("", "# Generated\n"),
1725        )
1726        .unwrap();
1727        let generated = handle(
1728            root.path(),
1729            &store,
1730            &post(".agents/sloop/tickets/generated.md", PostTrigger::Manual),
1731            2_200,
1732            "WORK",
1733            None,
1734            &flows(),
1735            "default",
1736        )
1737        .unwrap();
1738        assert_eq!(generated["ticket"]["id"], "WORK-10");
1739    }
1740
1741    #[test]
1742    fn paths_escaping_the_repository_are_rejected() {
1743        let (root, store) = world();
1744        let args = post("../outside.md", PostTrigger::Manual);
1745
1746        assert!(matches!(
1747            handle(
1748                root.path(),
1749                &store,
1750                &args,
1751                2_000,
1752                "TICK",
1753                None,
1754                &flows(),
1755                "default"
1756            ),
1757            Err(PostError::OutsideRepository(_))
1758        ));
1759    }
1760
1761    /// The repository root is always canonical in production, so an absolute
1762    /// path reaching the same file through a symlink has to resolve to it too.
1763    /// This is the normal case on macOS, where the temporary directory these
1764    /// tests run in is itself reached through a link into `/private`.
1765    #[test]
1766    fn absolute_paths_through_a_symlinked_root_stay_inside_the_repository() {
1767        let (root, store) = world();
1768        let link = tempdir().unwrap();
1769        let linked_root = link.path().join("repository");
1770        std::os::unix::fs::symlink(root.path(), &linked_root).unwrap();
1771
1772        let ticket = linked_root.join(".agents/sloop/tickets/linked.md");
1773        std::fs::write(&ticket, "---\nname: Linked\nblocked_by: []\n---\n\nBody\n").unwrap();
1774
1775        let posted = handle(
1776            root.path(),
1777            &store,
1778            &post(ticket.to_str().unwrap(), PostTrigger::Manual),
1779            2_000,
1780            "TICK",
1781            None,
1782            &flows(),
1783            "default",
1784        )
1785        .unwrap();
1786        assert_eq!(posted["ticket"]["name"], "Linked");
1787    }
1788
1789    /// A lexical check accepts a link sitting in the ticket directory and then
1790    /// reads whatever it points at. Containment is about the bytes that get
1791    /// read, so the target decides.
1792    #[test]
1793    fn ticket_files_symlinked_out_of_the_repository_are_rejected() {
1794        let (root, store) = world();
1795        let outside = tempdir().unwrap();
1796        let target = outside.path().join("elsewhere.md");
1797        std::fs::write(
1798            &target,
1799            "---\nname: Elsewhere\nblocked_by: []\n---\n\nBody\n",
1800        )
1801        .unwrap();
1802        std::os::unix::fs::symlink(&target, root.path().join(".agents/sloop/tickets/escape.md"))
1803            .unwrap();
1804
1805        assert!(matches!(
1806            handle(
1807                root.path(),
1808                &store,
1809                &post(".agents/sloop/tickets/escape.md", PostTrigger::Manual),
1810                2_000,
1811                "TICK",
1812                None,
1813                &flows(),
1814                "default",
1815            ),
1816            Err(PostError::OutsideRepository(_))
1817        ));
1818    }
1819
1820    /// Containment is decided before the file is read, so resolution must not
1821    /// turn a missing ticket into a containment failure.
1822    #[test]
1823    fn missing_ticket_files_inside_the_directory_still_report_not_found() {
1824        let (root, store) = world();
1825
1826        assert!(matches!(
1827            handle(
1828                root.path(),
1829                &store,
1830                &post(".agents/sloop/tickets/absent.md", PostTrigger::Manual),
1831                2_000,
1832                "TICK",
1833                None,
1834                &flows(),
1835                "default",
1836            ),
1837            Err(PostError::TicketFileNotFound(_))
1838        ));
1839    }
1840
1841    #[test]
1842    fn paths_outside_the_ticket_directory_are_rejected() {
1843        let (root, store) = world();
1844        std::fs::write(root.path().join("elsewhere.md"), "# Elsewhere\n").unwrap();
1845
1846        assert!(matches!(
1847            handle(
1848                root.path(),
1849                &store,
1850                &post("elsewhere.md", PostTrigger::Manual),
1851                2_000,
1852                "TICK",
1853                None,
1854                &flows(),
1855                "default",
1856            ),
1857            Err(PostError::OutsideTicketDirectory { .. })
1858        ));
1859    }
1860}