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
//! bl-chore's §14 SCRATCH — the record of what ONE claim minted, so the
//! rollback can take it back down (bl-ffbf).
//!
//! The plugin's only side effect is a NESTED `bl create`: a balls op with its
//! own commit point, sealed (and, in a tracked checkout, pushed) OUTSIDE the
//! claiming op's atom. When the claim then aborts, that child is the §14
//! appendix case — an artifact keyed to an op that never sealed, which nothing
//! ever converges onto — so the rollback must delete it, exactly as the jira
//! example deletes its orphan ticket.
//!
//! To delete it the rollback must KNOW it, and the ids cannot travel any other
//! way: §7 has no return channel and `BALLS_*` never crosses from one plugin
//! process to its later rollback process (§14). So they are written here, in the
//! plugin's own §1 territory, keyed by checkout and claimed ball — §14's
//! id-keyed scratch, the sanctioned `claim.post` writes / rollback reads
//! channel. The record is REWRITTEN (never appended) as each child lands, so it
//! always names exactly this claim's mints: a stale record from an earlier claim
//! of the same ball is overwritten by the first one, never inherited — a
//! rollback is scoped to ONE op invocation and must not reach back into a claim
//! that already succeeded. A successful claim's record is inert bytes the next
//! claim of that ball overwrites; only the rollback consumes it.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::Bl;
use crate::encoding::percent_encode;
/// The scratch for one (checkout, claimed ball) pair.
pub(super) struct Minted {
dir: PathBuf,
}
/// `<territory>/<pct-enc invocation_path>/<id>/` — the §1 plugin territory,
/// per-CHECKOUT (the ball it names lives in that checkout's store) then
/// id-keyed, the same shape §11's delivery worktree uses.
pub(super) fn at(territory: &Path, invocation: &str, id: &str) -> Minted {
Minted { dir: territory.join(percent_encode(invocation)).join(id) }
}
impl Minted {
/// The file holding the ids, one per line.
fn file(&self) -> PathBuf {
self.dir.join("children")
}
/// Rewrite the record to exactly the children this claim has minted so far.
/// Called after EACH mint, so a create that fails mid-list can still unwind
/// the ones that landed.
pub(super) fn record(&self, ids: &[String]) -> io::Result<()> {
fs::create_dir_all(&self.dir)?;
fs::write(self.file(), ids.join("\n"))
}
/// Close every recorded child, then drop the record. `close` IS the delete
/// here: a closed ball's `tasks/<id>.md` is REMOVED (§2, no archive dir), so
/// the gate stops gating and stops appearing — and in a tracked checkout the
/// nested close publishes that removal, which is the one place the orphan
/// actually persists (core's own un-seal resets the local store branch past
/// both the claim and the nested create). Idempotent: no record ⇒ nothing was
/// minted ⇒ nothing to undo, which is also the guarded-bail and the
/// nothing-configured case.
pub(super) fn unwind(&self, cwd: &Path, actor: &str, bl: &dyn Bl) -> io::Result<()> {
let Ok(recorded) = fs::read_to_string(self.file()) else {
return Ok(());
};
for child in recorded.lines() {
bl.run(cwd, &["close".to_string(), child.to_string(), "--as".to_string(), actor.to_string()])?;
}
fs::remove_dir_all(&self.dir)
}
}