tsk-bin 0.30.1

tsk — work with a clear context
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! The storage seam for the ledger.
//!
//! Every command that reads or writes ledger content goes through [`LedgerStore`]. The
//! Dolt backend in [`dolt`] (an external `dolt` binary and SQL tables) is the one
//! implementation. ADR 0015 removed the files backend and kept the trait. No method takes or returns a filesystem path as a way to reach
//! content: documents, threads, continuation entries, bindings and
//! external events are addressed by logical keys. The few paths in this module
//! (`RefreshOutcome::path`, `LedgerStore::path`) name where the store lives, for the
//! operator to read.

pub mod doc_tree;
pub mod dolt;
pub mod mysql;
mod renumber;
pub mod server;

use std::path::{Component, Path, PathBuf};

use crate::mission::model::Mission;

use super::status::LedgerStatus;

/// What a refresh found. The notes are ready to print to stderr, or `None`.
#[derive(Debug, Clone)]
pub struct RefreshOutcome {
    /// Where the store lives locally.
    pub path: PathBuf,
    /// Names the remote location, for example `origin's refs/heads/tsk/ledger`.
    pub remote_label: String,
    /// The identifier of the remote state the refresh fetched.
    pub remote_head: String,
    /// One line per local change that is not on the remote. Empty when there is none.
    pub pending: Vec<String>,
    /// True when the remote holds no ledger yet and the store is a new local one.
    #[allow(dead_code)]
    pub unpublished: bool,
    pub pending_note: Option<String>,
    pub unpublished_note: Option<String>,
    /// Says that an empty local ledger was replaced by origin's, or `None`.
    pub replaced_note: Option<String>,
}

/// The outcome of `commit_and_push`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PushReport {
    /// True when local changes were committed by this call.
    pub committed: bool,
    /// The identifier of the state the remote holds afterwards.
    pub head: String,
    /// True when the remote already held the local state and nothing was pushed.
    pub already_on_remote: bool,
    /// One line per row the push gave a new number, such as `T-03 is now T-05 in M-X`,
    /// because another clone had taken the number. Empty when no row moved.
    pub renumbered: Vec<String>,
}

/// What kind of actor a binding names.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum BindingKind {
    /// A Claude Code cloud session. The actor key is the session ID.
    Cloud,
    /// A local code worktree. The actor key is `<clone-id>/<worktree-name>`.
    Worktree,
}

impl BindingKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            BindingKind::Cloud => "cloud",
            BindingKind::Worktree => "worktree",
        }
    }

    pub fn parse(text: &str) -> Option<BindingKind> {
        match text {
            "cloud" => Some(BindingKind::Cloud),
            "worktree" => Some(BindingKind::Worktree),
            _ => None,
        }
    }
}

/// One row of the bindings: a harness instance (the `actor` column, kept under that name
/// from before 0.26.0) and the thread it works.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindingRow {
    pub actor: String,
    pub kind: BindingKind,
    pub thread_id: String,
    pub registered_at: String,
}

/// What an actor is: a person, or a program such as a coding agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorKind {
    Human,
    Robot,
}

impl ActorKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            ActorKind::Human => "human",
            ActorKind::Robot => "robot",
        }
    }

    pub fn parse(text: &str) -> Option<ActorKind> {
        match text {
            "human" => Some(ActorKind::Human),
            "robot" => Some(ActorKind::Robot),
            _ => None,
        }
    }
}

/// One row of `actors`: a human or a robot, by name. Its identity is its own, outside any
/// harness instance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActorRow {
    pub name: String,
    pub kind: ActorKind,
    pub registered_at: String,
}

/// One row of `instances`: a harness instance (a cloud session, or a code worktree of a
/// clone; the same key as `bindings.actor`) and the actor it registered as.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstanceRow {
    pub instance: String,
    pub kind: BindingKind,
    pub actor_name: String,
    pub registered_at: String,
}

/// The mission and briefing a thread stores. A thread made before layout version 5 has
/// no stored mission ID: [`crate::thread::ops::thread_mission`] derives it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ThreadInfo {
    pub mission_id: Option<String>,
    pub briefing_path: Option<String>,
}

pub trait LedgerStore {
    // --- Remote synchronisation and location ---

    /// Fetch the remote state and bring the local store up to it, creating the local
    /// store when it is absent. Refuses to overwrite uncommitted local changes. Prints
    /// nothing: the caller reports `pending_note` and `unpublished_note`.
    fn refresh(&self) -> Result<RefreshOutcome, String>;

    /// [`refresh`](LedgerStore::refresh) for `tsk ledger fetch`. The Dolt backend lets a
    /// version 1 ledger through, which every other command refuses until its documents
    /// are moved. Other backends refresh as usual.
    fn refresh_for_fetch(&self) -> Result<RefreshOutcome, String> {
        self.refresh()
    }

    /// [`refresh`](LedgerStore::refresh) for the thread commands. Local changes that are
    /// not committed are left as they are instead of refused, because a thread command
    /// pushes them with its own change, and the push merges the remote state into them.
    fn refresh_keeping_local(&self) -> Result<RefreshOutcome, String> {
        self.refresh()
    }

    /// True when the local store holds changes that are not committed: records or
    /// documents written since the last push.
    fn has_local_changes(&self) -> Result<bool, String> {
        Ok(false)
    }

    /// What the local store holds that origin does not, and how its committed state
    /// stands against origin. Fetches the remote refs into the store's remote-tracking
    /// state and nothing else: it does not merge, reset or touch the working set. When the
    /// fetch fails, `fetch_note` says so and the report comes from what is local.
    fn status(&self) -> Result<LedgerStatus, String>;

    /// [`status`](LedgerStatus) without asking origin anything: no network and no fetch.
    /// The commit counts come from what was last fetched, and
    /// [`LedgerStatus::origin_checked`] is false.
    fn status_local(&self) -> Result<LedgerStatus, String>;

    /// Drop every local change not yet pushed, in the data and the documents: uncommitted
    /// changes and local commits origin lacks. Fetches first and resets both sides to
    /// origin's state; when the fetch fails nothing is discarded. Leaves thread state
    /// kept outside the ledger alone.
    fn discard(&self) -> Result<(), String>;

    /// Where the local store lives. Has no side effects. Fails when the store has no
    /// identity yet (no clone id).
    fn path(&self) -> Result<PathBuf, String>;

    /// True when a local store has already been materialised. Has no side effects.
    fn exists_locally(&self) -> Result<bool, String>;

    /// Commit every local change, publish it to the remote with a bounded retry, and
    /// report the outcome. Each human-readable note (retry, nothing to commit, already
    /// on remote) goes to `notes` as it arises. Needs a refreshed local store.
    fn commit_and_push(
        &self,
        message: &str,
        notes: &mut dyn FnMut(&str),
    ) -> Result<PushReport, String>;

    /// Ask the store not to question origin first. A refresh or a push that would begin by
    /// asking origin what it holds takes the record of the last fetch for the answer, when
    /// the clone has one. A push of a clone whose record is out of date is rejected, and
    /// its retry fetches. A store that keeps no such record ignores this. Meant for a
    /// command that writes a row of its own and does not depend on the latest rows of others.
    fn trust_last_fetch(&self) {}

    /// Undo [`trust_last_fetch`](LedgerStore::trust_last_fetch): the next refresh asks origin.
    fn distrust_last_fetch(&self) {}

    /// The identifier of the local state, written into continuation entries as
    /// `git.ledger.commit`.
    fn head_id(&self) -> Result<String, String>;

    // --- Documents: free-form text keyed by a ledger-relative path ---

    fn doc_get(&self, path: &str) -> Result<Option<String>, String>;
    fn doc_put(&self, path: &str, body: &str) -> Result<(), String>;
    /// Every document path starting with `prefix` (a plain string prefix), sorted.
    /// Threads, external events and the manifest are not documents.
    fn doc_list(&self, prefix: &str) -> Result<Vec<String>, String>;
    /// Remove a document. Returns false when it did not exist.
    fn doc_remove(&self, path: &str) -> Result<bool, String>;
    fn doc_exists(&self, path: &str) -> Result<bool, String>;
    /// The absolute path of a document's file, or `None` when the document does not exist.
    fn doc_path(&self, path: &str) -> Result<Option<PathBuf>, String>;

    // --- Threads ---

    fn thread_exists(&self, id: &str) -> Result<bool, String>;
    /// Create a thread with an empty continuation log, the mission it works (none for a
    /// thread with no mission) and, for a mission with no record, its briefing path.
    /// Fails when the thread exists. The first call on a Dolt ledger below version 5
    /// upgrades it.
    fn thread_create(
        &self,
        id: &str,
        mission_id: Option<&str>,
        briefing_path: Option<&str>,
    ) -> Result<(), String>;
    /// Replace the mission and briefing path of an existing thread. Both `None` detaches
    /// the thread from its mission. Earlier continuation entries are not touched. The
    /// first call on a Dolt ledger below version 5 upgrades it.
    fn thread_set_mission(
        &self,
        id: &str,
        mission_id: Option<&str>,
        briefing_path: Option<&str>,
    ) -> Result<(), String>;
    /// Delete a thread and its continuation log.
    fn thread_delete(&self, id: &str) -> Result<(), String>;
    /// Every thread ID, sorted.
    fn thread_ids(&self) -> Result<Vec<String>, String>;
    /// What a thread stores: the mission it works and the briefing path it links to, each
    /// `None` when the thread records none. An unknown thread reads as neither.
    fn thread_info(&self, id: &str) -> Result<ThreadInfo, String>;

    // --- Continuation entries ---

    /// Append one JSON line (no trailing newline) to a thread's continuation log.
    fn continuation_append(&self, id: &str, line: &str) -> Result<(), String>;
    /// [`continuation_append`](LedgerStore::continuation_append), except that the write may
    /// be held back and made by the next [`commit_and_push`](LedgerStore::commit_and_push),
    /// in the same process as its commit. A read in between does not see the line. A store
    /// that holds nothing back writes it now.
    fn continuation_append_deferred(&self, id: &str, line: &str) -> Result<(), String> {
        self.continuation_append(id, line)
    }
    /// The stored lines of a thread's continuation log, oldest first, blank lines
    /// skipped. Empty for a thread with no log.
    fn continuation_list(&self, id: &str) -> Result<Vec<String>, String>;

    /// The stored continuation lines of each of `ids`, in the order given, as
    /// [`continuation_list`](LedgerStore::continuation_list) reads them one by one. A Dolt
    /// ledger reads them all in one process.
    fn continuation_lists(&self, ids: &[String]) -> Result<Vec<Vec<String>>, String> {
        ids.iter().map(|id| self.continuation_list(id)).collect()
    }

    // --- Bindings ---

    /// Every binding, sorted by registration time and then actor. Empty when nothing is
    /// bound. A Dolt ledger below version 8 reads its `cloud_session_bindings` table as
    /// cloud rows and upgrades nothing.
    fn bindings_list(&self) -> Result<Vec<BindingRow>, String>;
    /// Bind an actor to a thread, replacing the actor's earlier binding. The first call on
    /// a Dolt ledger below version 9 upgrades it. The change is local until the caller
    /// pushes it.
    fn binding_set(&self, row: &BindingRow) -> Result<(), String>;

    // --- Actors and harness instances ---

    /// Every actor, sorted by registration time and then name. Empty when none is
    /// registered, and on a Dolt ledger below version 9, which has no `actors` table; a
    /// read upgrades nothing.
    fn actors_list(&self) -> Result<Vec<ActorRow>, String>;
    /// Add an actor. The caller checks that the name is new: a second row with the same
    /// name is refused by the table. The first call on a Dolt ledger below version 9
    /// upgrades it. The change is local until the caller pushes it.
    fn actor_add(&self, row: &ActorRow) -> Result<(), String>;
    /// Every harness instance that registered as an actor, sorted by registration time
    /// and then key. Empty on a Dolt ledger below version 9.
    fn instances_list(&self) -> Result<Vec<InstanceRow>, String>;
    /// Register a harness instance as an actor, replacing its earlier registration. The
    /// first call on a Dolt ledger below version 9 upgrades it. The change is local until
    /// the caller pushes it.
    fn instance_set(&self, row: &InstanceRow) -> Result<(), String>;
    /// Remove an actor's binding, if it has one.
    fn binding_remove(&self, actor: &str) -> Result<(), String>;
    /// Remove the bindings of several actors in one write. An actor with no binding is
    /// skipped.
    fn bindings_remove(&self, actors: &[String]) -> Result<(), String>;
    /// Remove every binding to a thread, of either kind.
    fn binding_remove_thread(&self, thread_id: &str) -> Result<(), String>;

    // --- External events ---

    /// Append envelope text: one or more complete lines, each ending in `\n`.
    fn events_append(&self, lines: &str) -> Result<(), String>;
    /// Every queued line, oldest first, without terminators. `None` when no queue
    /// exists yet.
    fn events_list(&self) -> Result<Option<Vec<String>>, String>;
    /// The number of queued lines already processed. 0 when there is no watermark.
    fn watermark_get(&self) -> Result<u64, String>;
    fn watermark_set(&self, processed_through: u64, updated_at: &str) -> Result<(), String>;

    // --- Missions ---

    /// Every mission, sorted by ID. A ledger that predates missions has none. A version 1
    /// ledger is refused.
    fn missions_load(&self) -> Result<Vec<Mission>, String>;
    /// Write one mission and its objective outcomes, blockers and document links in the
    /// local store, replacing what the store held for that ID. The first call on a
    /// Dolt ledger below version 5 creates the missing tables and columns and records version 5.
    fn mission_save(&self, mission: &Mission) -> Result<(), String>;
    /// Write several missions in one local change, as [`mission_save`](Self::mission_save)
    /// writes one. A task, decision or question a mission no longer holds is removed.
    fn missions_save(&self, missions: &[Mission]) -> Result<(), String>;
    /// Change the code of the mission `from` to `to`, and nothing else: every other row
    /// names the mission by its internal ID. The first call on a Dolt ledger below version
    /// 10 upgrades it.
    fn mission_rename(&self, from: &str, to: &str) -> Result<(), String>;

    // --- Descriptions for error messages ---

    /// A human-readable location of a thread's continuation log.
    fn describe_thread(&self, id: &str) -> String;
    /// A human-readable location of the thread collection. Also seeds ID minting.
    fn describe_threads(&self) -> String;

    // --- Session start ---

    /// The command an operator runs to read local changes that are not on the remote,
    /// for the session start warning. `remote_head` is the remote state the refresh
    /// fetched.
    fn pending_inspect_hint(&self, path: &Path, remote_head: &str) -> String;
}

/// Open the ledger store for the repository containing `dir`. A ledger is a Dolt
/// database. `TSK_LEDGER_BACKEND=git` or `[ledger] backend = "git"` in the user config
/// names the removed files backend and stops with an error.
pub fn open(dir: &Path) -> Result<Box<dyn LedgerStore>, String> {
    crate::config::refuse_files_backend_from_env()?;
    dolt::DoltStore::open(dir).map(|store| Box::new(store) as Box<dyn LedgerStore>)
}

/// The components of a relative document path, with `.` dropped. Rejects an empty path,
/// an absolute path and a path with a `..` component.
fn relative_parts(path: &str) -> Result<Vec<String>, String> {
    let p = Path::new(path);
    let mut parts = Vec::new();
    for component in p.components() {
        match component {
            Component::Normal(name) => parts.push(name.to_string_lossy().into_owned()),
            Component::CurDir => {}
            _ => return Err(not_relative(path)),
        }
    }
    if path.is_empty() || parts.is_empty() {
        return Err(not_relative(path));
    }
    Ok(parts)
}

/// Paths a caller may name as documents on the `tsk/docs` branch, as the normalised
/// relative path. Only the rules of a file in git apply: no absolute path, no `..`, no
/// `.git` component. A path that is not valid UTF-8 cannot reach here as a `&str`.
pub fn check_docs_path(path: &str) -> Result<String, String> {
    let parts = relative_parts(path)?;
    if parts.iter().any(|part| part == ".git") {
        return Err(format!(
            "error: '{}' is not a document: .git is managed by git",
            path
        ));
    }
    Ok(parts.join("/"))
}

fn not_relative(path: &str) -> String {
    format!(
        "error: document path '{}' must be relative to the ledger root, with no '..'",
        path
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn docs_paths_apply_only_the_git_rules() {
        for (given, normal) in [
            ("index.md", "index.md"),
            ("./missions/M-X.md", "missions/M-X.md"),
            ("threads/1234abcd/index.md", "threads/1234abcd/index.md"),
            (
                "external-events/queue.ndjson",
                "external-events/queue.ndjson",
            ),
            (".tsk-ledger.toml", ".tsk-ledger.toml"),
        ] {
            assert_eq!(check_docs_path(given).unwrap(), normal, "{}", given);
        }
        for bad in [
            "",
            ".",
            "/etc/passwd",
            "../x.md",
            "a/../../x.md",
            ".git",
            "a/.git/config",
        ] {
            assert!(check_docs_path(bad).is_err(), "{}", bad);
        }
    }
}