Skip to main content

atelier_sdk/
journal.rs

1use std::fmt;
2use std::path::Path;
3use std::str::FromStr;
4
5use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
6use rusqlite::{Connection, params};
7
8use crate::config::ActorKind;
9use crate::error::{Error, engine_err};
10
11/// One thing an actor can do to a workspace that the journal records.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Act {
14    /// A workspace came into being.
15    WorkspaceInit,
16    /// A source was attached to the workspace; the reference names it.
17    SourceAttach,
18    /// A snapshot recorded the working copy; the reference names it.
19    Snapshot,
20    /// A format package failed or panicked over a document; the diff fell
21    /// back to the binary rung. The entry's reference names the document,
22    /// the package, and the reason — degradation is never silent.
23    PackageFailed,
24    /// A file exceeded the ladder's size cap; its delta stayed at the
25    /// binary rung. The entry's reference names the file and the cap.
26    FileTooLarge,
27    /// An actor opened a session; the entry carries the instruction's
28    /// summary and run reference (verbatim per policy, ADR-0004).
29    SessionOpen,
30    /// The session closed without landing; its work stays in history.
31    SessionAbandon,
32    /// A session opened a landing request; the reference names it.
33    LandRequest,
34    /// An actor approved a landing request; the reference names the
35    /// request and the snapshot the approval covers.
36    Approve,
37    /// An actor rejected a landing request; the reference names it and
38    /// carries the reason when one was given.
39    Reject,
40    /// A new snapshot on the change dismissed the request's approvals; the
41    /// reference names the request and the snapshot.
42    ApprovalsDismissed,
43    /// A change landed on the shared line; the reference names the request
44    /// and the landed snapshot.
45    Land,
46    /// A landing attempt hit a conflict and parked its request; the shared
47    /// line did not move. The reference names the request.
48    LandParked,
49    /// A landed line mirrored back to its folder source; the reference
50    /// names the source and the synced snapshot (ADR-0010).
51    Sync,
52    /// Bucket-side changes folded into a mounted line as one snapshot
53    /// (ADR-0012, the pull); the reference names the source and snapshot.
54    Pull,
55    /// A landed request stepped back off a line: the head returned to the
56    /// landed snapshot's parent, which the reference names with the
57    /// request (ADR-0011).
58    Undo,
59    /// A sync could not run — the origin changed out-of-band or refused
60    /// writes — and the landing stood anyway; the reference names the
61    /// source and snapshot. `atelier sync` retries; never silent.
62    SyncParked,
63}
64
65impl Act {
66    /// The act's canonical `snake_case` name, as stored and rendered.
67    #[must_use]
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::WorkspaceInit => "workspace_init",
71            Self::SourceAttach => "source_attach",
72            Self::Snapshot => "snapshot",
73            Self::PackageFailed => "package_failed",
74            Self::FileTooLarge => "file_too_large",
75            Self::SessionOpen => "session_open",
76            Self::SessionAbandon => "session_abandon",
77            Self::LandRequest => "land_request",
78            Self::Approve => "approve",
79            Self::Reject => "reject",
80            Self::ApprovalsDismissed => "approvals_dismissed",
81            Self::Land => "land",
82            Self::LandParked => "land_parked",
83            Self::Pull => "pull",
84            Self::Undo => "undo",
85            Self::Sync => "sync",
86            Self::SyncParked => "sync_parked",
87        }
88    }
89}
90
91impl fmt::Display for Act {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(self.as_str())
94    }
95}
96
97impl FromStr for Act {
98    type Err = Error;
99
100    fn from_str(text: &str) -> Result<Self, Self::Err> {
101        match text {
102            "workspace_init" => Ok(Self::WorkspaceInit),
103            "source_attach" => Ok(Self::SourceAttach),
104            "snapshot" => Ok(Self::Snapshot),
105            "package_failed" => Ok(Self::PackageFailed),
106            "file_too_large" => Ok(Self::FileTooLarge),
107            "session_open" => Ok(Self::SessionOpen),
108            "session_abandon" => Ok(Self::SessionAbandon),
109            "land_request" => Ok(Self::LandRequest),
110            "approve" => Ok(Self::Approve),
111            "reject" => Ok(Self::Reject),
112            "approvals_dismissed" => Ok(Self::ApprovalsDismissed),
113            "land" => Ok(Self::Land),
114            "land_parked" => Ok(Self::LandParked),
115            "pull" => Ok(Self::Pull),
116            "undo" => Ok(Self::Undo),
117            "sync" => Ok(Self::Sync),
118            "sync_parked" => Ok(Self::SyncParked),
119            other => Err(Error::Engine(format!("unknown journal act: {other}"))),
120        }
121    }
122}
123
124impl ToSql for Act {
125    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
126        Ok(ToSqlOutput::from(self.as_str()))
127    }
128}
129
130impl FromSql for Act {
131    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
132        let text = value.as_str()?;
133        Self::from_str(text).map_err(|error| FromSqlError::Other(error.to_string().into()))
134    }
135}
136
137impl ToSql for ActorKind {
138    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
139        Ok(ToSqlOutput::from(self.as_str()))
140    }
141}
142
143impl FromSql for ActorKind {
144    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
145        let text = value.as_str()?;
146        Self::from_str(text).map_err(|error| FromSqlError::Other(error.to_string().into()))
147    }
148}
149
150/// One record in a workspace's journal: who did what, and any intent behind it.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct JournalEntry {
153    /// When the act happened, in unix milliseconds.
154    pub at_ms: i64,
155    /// The acting actor's display name.
156    pub actor_name: String,
157    /// What kind of actor acted.
158    pub actor_kind: ActorKind,
159    /// What the actor did.
160    pub act: Act,
161    /// The session the act belongs to, when it happened inside one.
162    pub session: Option<String>,
163    /// The instruction's summary, on `session_open` entries.
164    pub instruction_summary: Option<String>,
165    /// A reference to the run that carried the instruction.
166    pub instruction_run_ref: Option<String>,
167    /// The instruction's verbatim body, when policy keeps it (ADR-0004).
168    pub instruction_verbatim: Option<String>,
169    /// What the act refers to, in the act's own terms: a snapshot, a
170    /// request, a source.
171    pub reference: Option<String>,
172}
173
174/// The append-only journal, a `SQLite` database beside the repo.
175pub struct Journal {
176    conn: Connection,
177}
178
179impl Journal {
180    /// Open (creating if absent) the journal at `path` and ensure its schema.
181    pub fn open(path: &Path) -> Result<Self, Error> {
182        let conn = crate::store::open_connection(path)?;
183        Ok(Self { conn })
184    }
185
186    /// Append one entry to the journal.
187    pub fn append(&self, entry: &JournalEntry) -> Result<(), Error> {
188        self.conn
189            .execute(
190                "INSERT INTO journal (
191                    at_ms, actor_name, actor_kind, act, session,
192                    instruction_summary, instruction_run_ref, instruction_verbatim, reference
193                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
194                params![
195                    entry.at_ms,
196                    entry.actor_name,
197                    entry.actor_kind,
198                    entry.act,
199                    entry.session,
200                    entry.instruction_summary,
201                    entry.instruction_run_ref,
202                    entry.instruction_verbatim,
203                    entry.reference,
204                ],
205            )
206            .map_err(engine_err)?;
207        Ok(())
208    }
209
210    /// Read up to `limit` entries, newest first.
211    pub fn entries(&self, limit: usize) -> Result<Vec<JournalEntry>, Error> {
212        let mut stmt = self
213            .conn
214            .prepare(
215                "SELECT at_ms, actor_name, actor_kind, act, session,
216                        instruction_summary, instruction_run_ref, instruction_verbatim, reference
217                 FROM journal ORDER BY id DESC LIMIT ?1",
218            )
219            .map_err(engine_err)?;
220        let limit = i64::try_from(limit).map_err(engine_err)?;
221        let rows = stmt
222            .query_map(params![limit], |row| {
223                Ok(JournalEntry {
224                    at_ms: row.get(0)?,
225                    actor_name: row.get(1)?,
226                    actor_kind: row.get(2)?,
227                    act: row.get(3)?,
228                    session: row.get(4)?,
229                    instruction_summary: row.get(5)?,
230                    instruction_run_ref: row.get(6)?,
231                    instruction_verbatim: row.get(7)?,
232                    reference: row.get(8)?,
233                })
234            })
235            .map_err(engine_err)?;
236        let mut entries = Vec::new();
237        for row in rows {
238            entries.push(row.map_err(engine_err)?);
239        }
240        Ok(entries)
241    }
242}