frame 0.1.7

A markdown task tracker with a terminal UI for humans and a CLI for agents
Documentation
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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
//! Durable **ID frontier**: the highest top-level task number handed out per
//! (project, prefix, namespace).
//!
//! Frame mints an ID by scanning the tracks it can see for the highest number in
//! its namespace and adding one. That scan is not a durable frontier — it moves
//! *backwards* whenever the live maximum drops:
//!
//! - `fr clean` archives a done task, taking its number out of the live file;
//! - `fr delete` removes one outright;
//! - a second git **worktree** of the same clone holds a working copy that has
//!   not merged the other's new tasks yet.
//!
//! The last one is why this module exists. Worktrees of one clone inherit a
//! single actor token, so they mint in the same namespace, and each computes its
//! frontier from its own working copy — so both hand out the same ID.
//!
//! The fix is to record every number handed out, in a file all worktrees of the
//! clone share:
//!
//! - inside git: `<git-common-dir>/frame-ids.toml` — every linked worktree
//!   resolves that to the same path, and nothing under `.git/` can be committed;
//! - outside git: `frame/.ids.toml`, where there are no worktrees to coordinate
//!   with (gitignored via [`crate::io::project_io::LOCAL_ONLY_FRAME_FILES`], so
//!   it stays local if the project is later put under git).
//!
//! A mint takes `max(scan, recorded) + 1` and records it *before* the task is
//! written, so a number is spoken for from the moment it is handed out. Frame
//! never reuses numbers and gaps are expected, so a mint that is later abandoned
//! costs nothing and needs no reclaiming.
//!
//! Everything here is regenerable cache, so failure handling is deliberately
//! blunt — the scan stays a floor, and every degraded path lands on the old
//! scan-only behavior rather than on a wrong answer:
//!
//! - **absent** (fresh clone, or deleted by hand): treated as empty.
//! - **unparsable**: moved aside to `<name>.bak` as a breadcrumb, treated as
//!   empty.
//! - **unwritable, or lock contention past the timeout**: the mint proceeds from
//!   the scan floor alone.
//! - **crash mid-write**: unobservable. Writes go to a temp file and `rename(2)`
//!   into place while holding a lock on a separate, never-removed lock file.
//!
//! Size is bounded by (projects × prefixes × namespaces minted locally) — a
//! handful of lines that never grow with the number of tasks.

use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

use crate::io::lock::FileLock;
use crate::model::task_id::Token;

/// Clone-shared store, under the git common directory.
const SHARED_STORE: &str = "frame-ids.toml";
const SHARED_LOCK: &str = "frame-ids.lock";

/// Working-copy-local store, for projects outside git. Both names are listed in
/// [`crate::io::project_io::LOCAL_ONLY_FRAME_FILES`].
pub const LOCAL_STORE: &str = ".ids.toml";
pub const LOCAL_LOCK: &str = ".ids.lock";

/// The store format this build writes. Read is version-agnostic: unknown fields
/// are ignored, so an older frame degrades to "unparsable → empty" at worst.
const FORMAT_VERSION: u32 = 1;

/// Waiting longer than this for the store lock means something is wrong; give up
/// and mint from the scan floor rather than hang a keystroke.
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);

/// How the null namespace is spelled as a store key, matching `actors.toml`.
const NULL_NAMESPACE: &str = "null";

const HEADER: &str = "\
# frame ID frontier — the highest task number handed out per project, prefix and
# actor namespace. Machine-local, never committed; shared by every git worktree
# of this clone so two worktrees can't mint the same task ID.
#
# Generated by frame. Safe to delete: minting falls back to scanning tracks and
# archives, which is correct but not collision-proof across worktrees.
";

// ---------------------------------------------------------------------------
// Store location
// ---------------------------------------------------------------------------

/// Where a project's frontier lives, and how the project is keyed inside it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreLocation {
    /// The TOML store itself, replaced atomically on every write.
    pub data: PathBuf,
    /// The lock guarding read-modify-write of `data`. A separate file because
    /// `data` is replaced by rename — see [`FileLock::acquire_at`].
    pub lock: PathBuf,
    /// This project's key within the store: the frame dir's repo-relative path
    /// (`frame` in the usual layout), so one clone holding several frame
    /// projects keeps a separate frontier for each. `.` outside git, where the
    /// store is already project-local.
    pub project: String,
}

/// Resolve the store location for the project at `frame_dir`. One `git` call.
pub fn locate(frame_dir: &Path) -> StoreLocation {
    let Some(paths) = crate::io::git::repo_paths(frame_dir) else {
        return StoreLocation {
            data: frame_dir.join(LOCAL_STORE),
            lock: frame_dir.join(LOCAL_LOCK),
            project: ".".to_string(),
        };
    };
    let project = frame_dir
        .canonicalize()
        .ok()
        .as_deref()
        .unwrap_or(frame_dir)
        .strip_prefix(&paths.toplevel)
        .map(|rel| rel.to_string_lossy().into_owned())
        .unwrap_or_else(|_| ".".to_string());
    StoreLocation {
        data: paths.common_dir.join(SHARED_STORE),
        lock: paths.common_dir.join(SHARED_LOCK),
        project,
    }
}

/// The store key for a namespace: the actor token, or `null` for the empty one.
fn namespace_key(token: Option<&Token>) -> &str {
    token.map_or(NULL_NAMESPACE, |t| t.as_str())
}

// ---------------------------------------------------------------------------
// Store contents
// ---------------------------------------------------------------------------

/// project key → ID prefix → namespace → highest number handed out.
type Projects = BTreeMap<String, BTreeMap<String, BTreeMap<String, u32>>>;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Frontier {
    /// Written for the benefit of future readers; ignored on read.
    #[serde(default)]
    version: u32,
    #[serde(default)]
    projects: Projects,
}

impl Frontier {
    fn get(&self, project: &str, prefix: &str, namespace: &str) -> u32 {
        self.projects
            .get(project)
            .and_then(|p| p.get(prefix))
            .and_then(|p| p.get(namespace))
            .copied()
            .unwrap_or(0)
    }

    /// Record `high` as handed out, never lowering an existing value.
    fn raise(&mut self, project: &str, prefix: &str, namespace: &str, high: u32) {
        let slot = self
            .projects
            .entry(project.to_string())
            .or_default()
            .entry(prefix.to_string())
            .or_default()
            .entry(namespace.to_string())
            .or_insert(0);
        *slot = (*slot).max(high);
    }
}

/// Where an unreadable store gets moved aside to. Left in place as the only
/// lasting evidence that a frontier was lost; `fr check` reports it.
fn backup_path(data: &Path) -> PathBuf {
    data.with_extension("toml.bak")
}

/// Parse the store without touching it. `Ok(None)` means there is no store yet;
/// `Err` carries the parse failure for reporting.
fn parse(path: &Path) -> Result<Option<Frontier>, String> {
    let Ok(text) = fs::read_to_string(path) else {
        return Ok(None);
    };
    toml::from_str::<Frontier>(&text)
        .map(Some)
        .map_err(|e| e.to_string())
}

/// Read the store for a mint. An absent store is empty; an unparsable one is
/// moved aside to `<name>.bak` and treated as empty, so a corrupt store costs the
/// frontier but never a failed mint.
fn read_or_reset(path: &Path) -> Frontier {
    match parse(path) {
        Ok(Some(frontier)) => frontier,
        Ok(None) => Frontier::default(),
        Err(_) => {
            let _ = fs::rename(path, backup_path(path));
            Frontier::default()
        }
    }
}

fn write(path: &Path, frontier: &Frontier) {
    let Ok(body) = toml::to_string_pretty(frontier) else {
        return;
    };
    let content = format!("{}\n{}", HEADER, body);
    // Best effort: a store that can't be written costs durability, not
    // correctness — the next mint falls back to the scan floor.
    let _ = crate::io::recovery::atomic_write(path, content.as_bytes());
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// The highest number recorded for a namespace, or 0 when nothing is recorded
/// (no store, no entry, or an unreadable store). Read-only: takes no lock, hands
/// out nothing, and leaves even an unreadable store where it is.
pub fn recorded(frame_dir: &Path, prefix: &str, token: Option<&Token>) -> u32 {
    let at = locate(frame_dir);
    parse(&at.data)
        .ok()
        .flatten()
        .map(|f| f.get(&at.project, prefix, namespace_key(token)))
        .unwrap_or(0)
}

/// Every prefix with a recorded frontier in `token`'s namespace, highest number
/// handed out for each. Read-only, for `fr info`.
pub fn recorded_by_prefix(frame_dir: &Path, token: Option<&Token>) -> BTreeMap<String, u32> {
    let at = locate(frame_dir);
    let namespace = namespace_key(token);
    let Ok(Some(frontier)) = parse(&at.data) else {
        return BTreeMap::new();
    };
    frontier
        .projects
        .get(&at.project)
        .map(|prefixes| {
            prefixes
                .iter()
                .filter_map(|(prefix, namespaces)| {
                    namespaces.get(namespace).map(|n| (prefix.clone(), *n))
                })
                .collect()
        })
        .unwrap_or_default()
}

/// What state the store is in. Read-only — unlike a mint, which resets an
/// unparsable store, this reports it so `fr check` can say so before the frontier
/// is lost.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreState {
    /// Nothing recorded yet: minting relies on the scan floor alone. Normal for a
    /// project that hasn't minted since the store existed.
    Absent,
    /// Parses cleanly.
    Ok,
    /// Present but unparsable. The next mint moves it aside and the recorded
    /// frontier is lost, dropping back to scan-only minting.
    Unparsable(String),
}

/// The store's location and condition, for `fr check`.
#[derive(Debug, Clone)]
pub struct StoreHealth {
    pub path: PathBuf,
    pub state: StoreState,
    /// A `.bak` left behind by an earlier reset — the frontier was lost at least
    /// once, so numbers may have been reissued in that window.
    pub reset_backup: Option<PathBuf>,
}

/// Inspect the store without modifying it.
pub fn health(frame_dir: &Path) -> StoreHealth {
    let at = locate(frame_dir);
    let state = match parse(&at.data) {
        Ok(Some(_)) => StoreState::Ok,
        Ok(None) => StoreState::Absent,
        Err(detail) => StoreState::Unparsable(detail),
    };
    let backup = backup_path(&at.data);
    StoreHealth {
        path: at.data,
        state,
        reset_backup: backup.is_file().then_some(backup),
    }
}

/// Reserve `n` consecutive numbers above `floor` for the given namespace and
/// return the first. The reservation is recorded before returning, so every
/// other worktree of this clone mints above it from that instant.
///
/// `floor` is the caller's own scan of what it can see; the result is always
/// above both `floor` and whatever the store already recorded. Never fails: with
/// no usable store at all this is `floor + 1`, i.e. the scan-only behavior.
pub fn reserve(frame_dir: &Path, prefix: &str, token: Option<&Token>, floor: u32, n: u32) -> u32 {
    let n = n.max(1);
    let at = locate(frame_dir);
    let namespace = namespace_key(token);

    // Without the lock a read-modify-write could hand the same number to two
    // processes, so don't attempt one — but still honor what's already recorded.
    // Reads need no lock: the store is only ever replaced by rename, so a reader
    // sees one whole version or another, never a torn one.
    let Ok(_guard) = FileLock::acquire_at(&at.lock, LOCK_TIMEOUT) else {
        return floor.max(recorded(frame_dir, prefix, token)) + 1;
    };

    let mut frontier = read_or_reset(&at.data);
    let start = floor.max(frontier.get(&at.project, prefix, namespace)) + 1;
    frontier.version = FORMAT_VERSION;
    frontier.raise(&at.project, prefix, namespace, start + n - 1);
    write(&at.data, &frontier);
    start
}

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

    fn frame_dir(tmp: &TempDir) -> PathBuf {
        let dir = tmp.path().join("frame");
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn reserve_advances_past_the_floor_and_records_it() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);

        assert_eq!(reserve(&frame, "T", None, 7, 1), 8);
        assert_eq!(recorded(&frame, "T", None), 8);
        // A stale floor no longer wins: the record carries the frontier.
        assert_eq!(reserve(&frame, "T", None, 7, 1), 9);
        assert_eq!(recorded(&frame, "T", None), 9);
    }

    #[test]
    fn reserve_n_hands_out_a_block() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);

        assert_eq!(reserve(&frame, "T", None, 0, 5), 1);
        assert_eq!(recorded(&frame, "T", None), 5);
        assert_eq!(reserve(&frame, "T", None, 0, 1), 6);
    }

    #[test]
    fn a_higher_floor_still_wins() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);

        reserve(&frame, "T", None, 0, 1); // records 1
        // Tasks arrived from elsewhere (a merge): the scan sees further than the
        // store, so the store catches up.
        assert_eq!(reserve(&frame, "T", None, 40, 1), 41);
        assert_eq!(recorded(&frame, "T", None), 41);
    }

    #[test]
    fn namespaces_and_prefixes_are_independent() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        let token = Token::new("b").unwrap();

        assert_eq!(reserve(&frame, "T", None, 0, 3), 1);
        assert_eq!(reserve(&frame, "T", Some(&token), 0, 1), 1);
        assert_eq!(reserve(&frame, "OTH", None, 0, 1), 1);
        assert_eq!(recorded(&frame, "T", None), 3);
        assert_eq!(recorded(&frame, "T", Some(&token)), 1);
        assert_eq!(recorded(&frame, "OTH", None), 1);
    }

    #[test]
    fn a_missing_store_reads_as_empty() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        assert_eq!(recorded(&frame, "T", None), 0);
    }

    #[test]
    fn a_corrupt_store_is_moved_aside_and_minting_continues() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        reserve(&frame, "T", None, 0, 9); // records 9
        let at = locate(&frame);
        fs::write(&at.data, "this is not toml {{{").unwrap();

        // The frontier is lost, so the floor is all that's left — the old
        // scan-only behavior, never a failure.
        assert_eq!(reserve(&frame, "T", None, 3, 1), 4);
        assert!(at.data.with_extension("toml.bak").exists());
        // And the store rebuilds from there.
        assert_eq!(recorded(&frame, "T", None), 4);
    }

    #[test]
    fn a_deleted_store_falls_back_to_the_floor() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        reserve(&frame, "T", None, 0, 9);
        let at = locate(&frame);
        fs::remove_file(&at.data).unwrap();

        assert_eq!(reserve(&frame, "T", None, 2, 1), 3);
    }

    #[test]
    fn every_worktree_of_a_clone_shares_one_store() {
        let tmp = TempDir::new().unwrap();
        let Some((main, worktree)) = crate::io::git::testutil::repo_with_worktree(tmp.path())
        else {
            return; // git unavailable
        };
        let from_main = locate(&main);
        let from_worktree = locate(&worktree);
        assert_eq!(from_main, from_worktree);
        assert_eq!(from_main.project, "frame");
        assert!(from_main.data.ends_with(SHARED_STORE));

        // So a number handed out in one is unavailable in the other, even though
        // each scans only its own working copy.
        assert_eq!(reserve(&main, "T", None, 0, 1), 1);
        assert_eq!(reserve(&worktree, "T", None, 0, 1), 2);
        assert_eq!(reserve(&main, "T", None, 0, 1), 3);
    }

    #[test]
    fn a_non_git_project_keeps_its_store_local() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        if crate::io::git::repo_paths(&frame).is_some() {
            return; // tempdir sits inside a repo
        }
        let at = locate(&frame);
        assert_eq!(at.data, frame.join(LOCAL_STORE));
        assert_eq!(at.lock, frame.join(LOCAL_LOCK));
        assert_eq!(at.project, ".");
    }

    #[test]
    fn health_reports_absent_ok_and_unparsable_without_touching_the_store() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);

        assert_eq!(health(&frame).state, StoreState::Absent);
        assert!(health(&frame).reset_backup.is_none());

        reserve(&frame, "T", None, 0, 1);
        assert_eq!(health(&frame).state, StoreState::Ok);

        let at = locate(&frame);
        fs::write(&at.data, "not toml {{{").unwrap();
        assert!(matches!(health(&frame).state, StoreState::Unparsable(_)));
        // Probing must not reset it — that's a mint's job, and check needs to
        // report the problem while it is still there.
        assert!(at.data.is_file());
        assert!(!backup_path(&at.data).exists());
        assert_eq!(recorded(&frame, "T", None), 0);
        assert!(recorded_by_prefix(&frame, None).is_empty());
    }

    #[test]
    fn health_surfaces_a_leftover_reset_backup() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        reserve(&frame, "T", None, 0, 5);
        let at = locate(&frame);
        fs::write(&at.data, "not toml {{{").unwrap();

        // A mint resets it, leaving the .bak as evidence the frontier was lost.
        reserve(&frame, "T", None, 0, 1);
        let health = health(&frame);
        assert_eq!(health.state, StoreState::Ok);
        assert_eq!(health.reset_backup, Some(backup_path(&at.data)));
    }

    #[test]
    fn recorded_by_prefix_lists_one_namespace() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        let token = Token::new("c").unwrap();
        reserve(&frame, "DEM", None, 0, 4);
        reserve(&frame, "ENG", None, 0, 2);
        reserve(&frame, "DEM", Some(&token), 0, 9);

        let null_ns = recorded_by_prefix(&frame, None);
        assert_eq!(null_ns.get("DEM"), Some(&4));
        assert_eq!(null_ns.get("ENG"), Some(&2));
        assert_eq!(null_ns.len(), 2);

        let c_ns = recorded_by_prefix(&frame, Some(&token));
        assert_eq!(c_ns.get("DEM"), Some(&9));
        assert_eq!(c_ns.len(), 1);
    }

    #[test]
    fn the_store_is_readable_toml_with_a_header() {
        let tmp = TempDir::new().unwrap();
        let frame = frame_dir(&tmp);
        reserve(&frame, "DEM", None, 0, 1);
        let text = fs::read_to_string(locate(&frame).data).unwrap();
        assert!(text.starts_with("# frame ID frontier"));
        assert!(text.contains("version = 1"));
        assert!(text.contains("DEM"));
        assert!(text.contains(NULL_NAMESPACE));
    }
}