Skip to main content

kranz_engine/
ticket_notes.rs

1//! Ticket discussion notes — `.kranz/tickets/<slug>.notes.jsonl` (D-BW-3,
2//! adopted from beads' flat `{author, text, created_at}` comment model).
3//!
4//! One JSON object per line — `{ts, author, text}` — appended only: there is
5//! no edit or delete, mirroring the event log's honesty posture. Notes are
6//! the ticket-scoped "why" channel the frontmatter (structured) and body
7//! (authored once) cannot carry; they are COMMITTED artifacts (same class as
8//! the ticket `.md` itself — see AGENTS.md's tracked-vs-runtime list), not
9//! gitignored runtime state.
10//!
11//! Durability follows the [`crate::event_log`] idiom: open O_APPEND (creating
12//! on first append), one `write_all` of the full line, flush, fsync. Appends
13//! are clock-stamped at write time; file order IS chronological order, so
14//! reads never re-sort.
15
16use crate::error::{EngineError, Result};
17use crate::ticket::Ticket;
18use serde::{Deserialize, Serialize};
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22/// How many notes [`draft_context`] folds into the drafter's seed — the most
23/// recent N, so a long discussion cannot blow up the prompt.
24pub const MAX_DRAFT_NOTES: usize = 50;
25
26/// One note on a ticket. Field order is the on-disk key order (`ts`,
27/// `author`, `text`); additive-only like every persisted shape here.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct TicketNote {
30    pub ts: chrono::DateTime<chrono::Utc>,
31    pub author: String,
32    pub text: String,
33}
34
35/// Path of the notes sidecar for a slug. Slug validation happens in
36/// [`append_note`]/[`read_notes`] BEFORE this is ever joined, so a
37/// traversal-shaped slug cannot escape `.kranz/tickets/` (the same
38/// safe-id discipline as ticket reads).
39fn notes_path(repo_root: &Path, slug: &str) -> PathBuf {
40    Ticket::tickets_dir(repo_root).join(format!("{slug}.notes.jsonl"))
41}
42
43/// Append one note to a ticket's discussion, creating the sidecar on first
44/// append. `author` is supplied by the caller (the CLI resolves
45/// `KRANZ_NOTE_AUTHOR` → `operator`); `text` is trimmed, scrubbed (the file
46/// is committed — the same posture as text the engine writes into ticket
47/// bodies), and must not be empty. Returns the stored note.
48pub fn append_note(repo_root: &Path, slug: &str, author: &str, text: &str) -> Result<TicketNote> {
49    Ticket::ensure_valid_slug(slug)?;
50    let author = author.trim();
51    if author.is_empty() {
52        return Err(EngineError::Config(
53            "note author must not be empty (set KRANZ_NOTE_AUTHOR or omit it for 'operator')"
54                .to_string(),
55        ));
56    }
57    let text = crate::scrub::scrub(text.trim());
58    if text.is_empty() {
59        return Err(EngineError::Config(format!(
60            "refusing to record an empty note on ticket '{slug}'"
61        )));
62    }
63    let note = TicketNote {
64        ts: chrono::Utc::now(),
65        author: author.to_string(),
66        text,
67    };
68    let mut line = serde_json::to_string(&note)?;
69    line.push('\n');
70
71    let dir = Ticket::tickets_dir(repo_root);
72    std::fs::create_dir_all(&dir)?;
73    let path = notes_path(repo_root, slug);
74    // Never append through a planted symlink (5th-pass review): a committed
75    // `<slug>.notes.jsonl` symlink would redirect the append into any
76    // same-user writable file. O_NOFOLLOW on unix makes the open itself
77    // refuse (ELOOP); off-unix the check-then-open window is documented
78    // (Windows symlink creation needs privileges).
79    #[cfg(unix)]
80    let mut file = {
81        use std::os::unix::fs::OpenOptionsExt as _;
82        std::fs::OpenOptions::new()
83            .append(true)
84            .create(true)
85            .custom_flags(libc::O_NOFOLLOW)
86            .open(&path)
87            .map_err(|e| {
88                if e.raw_os_error() == Some(libc::ELOOP) {
89                    EngineError::InvalidState(format!(
90                        "refusing ticket notes sidecar that is a symlink: {}",
91                        path.display()
92                    ))
93                } else {
94                    EngineError::Io(e)
95                }
96            })?
97    };
98    #[cfg(not(unix))]
99    let mut file = {
100        crate::paths::ensure_absent_or_regular_file(&path)?;
101        std::fs::OpenOptions::new()
102            .append(true)
103            .create(true)
104            .open(&path)?
105    };
106    // The event-log idiom: O_APPEND + create, a single write of the whole
107    // line, then fsync — a concurrent appender can interleave between notes
108    // but never within one.
109    file.write_all(line.as_bytes())?;
110    file.flush()?;
111    file.sync_data()?;
112    Ok(note)
113}
114
115/// Read every note in file order (== chronological order, oldest first). A
116/// missing sidecar is the normal "no notes yet" case and reads as empty. A
117/// malformed line is an [`EngineError::Config`] naming the file and line —
118/// notes are a committed, tool-maintained record, so corruption is surfaced
119/// for repair rather than silently skipped.
120pub fn read_notes(repo_root: &Path, slug: &str) -> Result<Vec<TicketNote>> {
121    Ticket::ensure_valid_slug(slug)?;
122    let path = notes_path(repo_root, slug);
123    let text = match crate::paths::open_read_nofollow(&path) {
124        Ok(mut file) => {
125            use std::io::Read as _;
126            let mut text = String::new();
127            file.read_to_string(&mut text)?;
128            text
129        }
130        Err(e) if matches!(&e, EngineError::Io(io) if io.kind() == std::io::ErrorKind::NotFound) => {
131            return Ok(Vec::new())
132        }
133        Err(e) => return Err(e),
134    };
135    let mut notes = Vec::new();
136    for (i, line) in text.lines().enumerate() {
137        if line.trim().is_empty() {
138            continue;
139        }
140        let note = serde_json::from_str::<TicketNote>(line).map_err(|e| {
141            EngineError::Config(format!(
142                "corrupt ticket notes file {} (line {}): {e}",
143                path.display(),
144                i + 1
145            ))
146        })?;
147        notes.push(note);
148    }
149    Ok(notes)
150}
151
152/// The markdown section [`crate::draft::drive_draft`] appends to the
153/// drafter's seed: the most recent [`MAX_DRAFT_NOTES`] notes as bullets, with
154/// an omission marker when older ones were capped. `None` when the ticket
155/// has no notes (the seed is then exactly the folded ticket, as before).
156pub fn draft_context(repo_root: &Path, slug: &str) -> Result<Option<String>> {
157    let notes = read_notes(repo_root, slug)?;
158    if notes.is_empty() {
159        return Ok(None);
160    }
161    let start = notes.len().saturating_sub(MAX_DRAFT_NOTES);
162    let mut out = String::from("\n\n## Ticket notes\n");
163    if start > 0 {
164        out.push_str(&format!(
165            "(most recent {MAX_DRAFT_NOTES} of {} notes)\n",
166            notes.len()
167        ));
168    }
169    for note in &notes[start..] {
170        out.push_str(&format!(
171            "- [{}] {}: {}\n",
172            note.ts.to_rfc3339(),
173            note.author,
174            note.text
175        ));
176    }
177    Ok(Some(out))
178}