heph-core: recurrence (roll-forward in place) + per-task logs
Some checks failed
Build / validate (pull_request) Failing after 4s

Slice 5 (tech-spec §4.4). Completing a recurring task rolls it forward in
place instead of marking it done — the Todoist-corner-avoiding model.

Pure recurrence module:
- next_occurrence(rrule, anchor, after): lazy RRULE expansion (rrule +
  chrono/UTC) returning the next instance strictly after `after`,
  skipping missed occurrences; None when a finite series is exhausted.
- reset_checkboxes(body): the fresh-checklist transform — unchecks every
  `- [x]`, idempotent, preserves indentation/bullet/line-endings.

Storage roll-forward (one transaction, on set_state(done) of a recurring
task): reset the canonical context doc's checklist, append the completed
occurrence to the task's log, advance do_date to the next instance after
now (skipping misses); finite series finally goes done. `skip` advances
the same way without logging. Non-recurring done is unchanged.

Per-task append-only log (`log-of` doc): log_append / log_tail — the
resumption breadcrumb + recurring-completion narrative ([[design]] §6.4).

Tests: 7 recurrence unit + 2 proptests (no checked marker survives reset;
reset idempotent for any body) + 6 end-to-end incl. five-occurrence
no-carry-forward and missed-collapse-to-one. 53 tests green. This
completes the heph-core library layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-05-31 19:14:22 -07:00
commit d0debfceb9
12 changed files with 878 additions and 16 deletions

289
Cargo.lock generated
View file

@ -14,6 +14,24 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@ -63,6 +81,47 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
"phf_codegen",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "errno"
version = "0.3.14"
@ -161,13 +220,39 @@ dependencies = [
name = "heph-core"
version = "0.0.0"
dependencies = [
"chrono",
"proptest",
"pulldown-cmark",
"rrule",
"rusqlite",
"thiserror",
"thiserror 2.0.18",
"ulid",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "js-sys"
version = "0.3.99"
@ -180,6 +265,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.186"
@ -203,6 +294,12 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "memchr"
version = "2.8.1"
@ -224,6 +321,53 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.6",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -264,7 +408,7 @@ dependencies = [
"bit-vec",
"bitflags",
"num-traits",
"rand",
"rand 0.9.4",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
@ -305,6 +449,15 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
@ -312,7 +465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@ -322,9 +475,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@ -340,7 +499,30 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
@ -349,6 +531,20 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rrule"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cff1ca93145ff07cdc878b5f6bb90391a299cc8712538af0ad73ebf37613e46a"
dependencies = [
"chrono",
"chrono-tz",
"lazy_static",
"log",
"regex",
"thiserror 1.0.69",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@ -400,6 +596,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.12"
@ -436,13 +638,33 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@ -462,7 +684,7 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand",
"rand 0.9.4",
"web-time",
]
@ -569,12 +791,65 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"

View file

@ -16,6 +16,8 @@ ulid = "1"
thiserror = "2"
anyhow = "1"
pulldown-cmark = { version = "0.13", default-features = false }
rrule = "0.13"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[profile.release]
lto = "thin"

View file

@ -13,6 +13,8 @@ rusqlite.workspace = true
ulid.workspace = true
thiserror.workspace = true
pulldown-cmark.workspace = true
rrule.workspace = true
chrono.workspace = true
[dev-dependencies]
proptest = "1"

View file

@ -13,6 +13,7 @@ pub mod error;
pub mod extract;
pub mod model;
pub mod ranking;
pub mod recurrence;
pub mod sqlite;
pub mod store;
@ -21,5 +22,6 @@ pub use error::{Error, Result};
pub use extract::{extract, ContextItem, Extraction};
pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState};
pub use ranking::{rank, Dimension, RankedTask, RANKING};
pub use recurrence::{next_occurrence, reset_checkboxes};
pub use sqlite::LocalStore;
pub use store::Store;

View file

@ -0,0 +1,185 @@
//! Recurrence — roll-forward in place (tech-spec §4.4, [[design]] §3.3).
//!
//! A recurring task is a **single node** carrying an RFC-5545 RRULE. On
//! completing an occurrence it (1) resets its checklist to all-unchecked,
//! (2) logs the occurrence, and (3) advances its do-date to the **next RRULE
//! instance strictly after now**, *skipping* missed occurrences. So a missed
//! daily routine is one gently-overdue item, never a pile, and **completion
//! never carries forward**.
//!
//! This module holds the two pure pieces: [`next_occurrence`] (lazy RRULE
//! expansion — only the next instance is ever computed) and
//! [`reset_checkboxes`] (the fresh-checklist transform).
use chrono::TimeZone;
use rrule::{RRule, Tz, Unvalidated};
use crate::error::{Error, Result};
/// Hard cap on how many series instances we'll skip past while searching for
/// the next one after `now`. Protects against pathological iteration; a normal
/// missed-routine gap is a handful of instances.
const MAX_SKIP: usize = 100_000;
/// The next RRULE instance **strictly after** `after_ms`, in epoch ms, anchored
/// at `anchor_ms` (the series DTSTART — in practice the task's current do-date,
/// which is itself a series instance). `None` if the series is exhausted
/// (a finite rule with `COUNT`/`UNTIL`).
///
/// Lazy: it walks instances from the anchor and returns the first past `after`,
/// never materializing the series.
pub fn next_occurrence(rrule: &str, anchor_ms: i64, after_ms: i64) -> Result<Option<i64>> {
let anchor = to_dt(anchor_ms)?;
let after = to_dt(after_ms)?;
let rule: RRule<Unvalidated> = rrule
.parse()
.map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?;
let set = rule
.build(anchor)
.map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?;
for (i, occ) in set.into_iter().enumerate() {
if i >= MAX_SKIP {
return Err(Error::Integrity(format!(
"recurrence search for {rrule:?} exceeded {MAX_SKIP} instances"
)));
}
if occ.timestamp_millis() > after_ms && occ > after {
return Ok(Some(occ.timestamp_millis()));
}
}
Ok(None)
}
fn to_dt(ms: i64) -> Result<chrono::DateTime<Tz>> {
Tz::UTC
.timestamp_millis_opt(ms)
.single()
.ok_or_else(|| Error::Integrity(format!("invalid timestamp {ms}")))
}
/// Return `body` with every checked GFM task marker (`- [x]` / `- [X]`) reset to
/// unchecked (`- [ ]`). Idempotent; preserves indentation, bullet style
/// (`-`/`*`/`+`), and line endings. Unchecked items are left untouched.
pub fn reset_checkboxes(body: &str) -> String {
body.split_inclusive('\n').map(uncheck_line).collect()
}
fn uncheck_line(line: &str) -> String {
let indent_len = line.len() - line.trim_start().len();
let (indent, rest) = line.split_at(indent_len);
let b = rest.as_bytes();
let is_checked_item = b.len() >= 5
&& matches!(b[0], b'-' | b'*' | b'+')
&& b[1] == b' '
&& b[2] == b'['
&& matches!(b[3], b'x' | b'X')
&& b[4] == b']';
if is_checked_item {
let bullet = &rest[0..1];
let tail = &rest[5..]; // everything after "]" (incl. any trailing '\n')
format!("{indent}{bullet} [ ]{tail}")
} else {
line.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
// 2024-01-01T00:00:00Z and friends, in epoch ms.
const JAN1: i64 = 1_704_067_200_000;
const ONE_DAY: i64 = 86_400_000;
#[test]
fn daily_advances_one_day_when_completed_on_time() {
// anchor = Jan 1; completing at Jan 1 noon → next instance Jan 2.
let next = next_occurrence("FREQ=DAILY", JAN1, JAN1 + ONE_DAY / 2)
.unwrap()
.unwrap();
assert_eq!(next, JAN1 + ONE_DAY);
}
#[test]
fn daily_skips_missed_occurrences() {
// anchor Jan 1, but we don't complete until 3.5 days later → next is
// Jan 5 (one item, not a pile of four).
let now = JAN1 + 3 * ONE_DAY + ONE_DAY / 2;
let next = next_occurrence("FREQ=DAILY", JAN1, now).unwrap().unwrap();
assert_eq!(next, JAN1 + 4 * ONE_DAY);
}
#[test]
fn weekly_advances_a_week() {
let next = next_occurrence("FREQ=WEEKLY", JAN1, JAN1 + ONE_DAY)
.unwrap()
.unwrap();
assert_eq!(next, JAN1 + 7 * ONE_DAY);
}
#[test]
fn finite_rule_can_be_exhausted() {
// COUNT=2 from the anchor → instances at Jan 1 and Jan 2; after Jan 2
// there is no next.
let after_last = JAN1 + 5 * ONE_DAY;
assert_eq!(
next_occurrence("FREQ=DAILY;COUNT=2", JAN1, after_last).unwrap(),
None
);
}
#[test]
fn invalid_rrule_is_an_error() {
assert!(next_occurrence("FREQ=NONSENSE", JAN1, JAN1).is_err());
}
#[test]
fn reset_unchecks_all_checked_items() {
let body = "- [x] a\n- [ ] b\n* [X] c\n + [x] nested\n";
let expected = "- [ ] a\n- [ ] b\n* [ ] c\n + [ ] nested\n";
assert_eq!(reset_checkboxes(body), expected);
}
#[test]
fn reset_is_idempotent() {
let body = "- [x] a\n- [ ] b\n";
let once = reset_checkboxes(body);
assert_eq!(reset_checkboxes(&once), once);
}
#[test]
fn reset_leaves_non_checkbox_text_alone() {
let body = "# Heading\n\nSome [x] not a checkbox\n- regular item\n";
assert_eq!(reset_checkboxes(body), body);
}
proptest! {
/// The fresh-checklist invariant: after a reset, no checked task marker
/// remains — completion can never carry forward (tech-spec §9).
#[test]
fn reset_leaves_no_checked_markers(body in ".{0,200}") {
let out = reset_checkboxes(&body);
for line in out.lines() {
let t = line.trim_start();
let bytes = t.as_bytes();
let checked = bytes.len() >= 5
&& matches!(bytes[0], b'-' | b'*' | b'+')
&& bytes[1] == b' '
&& bytes[2] == b'['
&& matches!(bytes[3], b'x' | b'X')
&& bytes[4] == b']';
prop_assert!(!checked, "checked marker survived reset: {line:?}");
}
}
/// Reset is idempotent for any input.
#[test]
fn reset_idempotent_for_any_body(body in ".{0,200}") {
let once = reset_checkboxes(&body);
prop_assert_eq!(reset_checkboxes(&once), once);
}
}
}

View file

@ -53,6 +53,25 @@ pub(super) fn add(
Ok(link)
}
/// The destination of the first non-tombstoned link of `link_type` out of
/// `src_id`, if any (e.g. a task's canonical-context doc or its log doc).
pub(super) fn first_dst(
conn: &Connection,
src_id: &str,
link_type: LinkType,
) -> Result<Option<String>> {
let dst = conn
.query_row(
"SELECT dst_id FROM links
WHERE src_id = ?1 AND type = ?2 AND tombstoned = 0
ORDER BY created_at, id LIMIT 1",
(src_id, link_type.as_str()),
|r| r.get(0),
)
.optional()?;
Ok(dst)
}
/// All non-tombstoned links originating at `id`.
pub(super) fn outgoing(conn: &Connection, id: &str) -> Result<Vec<Link>> {
query(conn, "src_id", id)

View file

@ -0,0 +1,83 @@
//! Per-task append-only log ([[design]] §6.4).
//!
//! A task's log is a `doc` node linked from the task by `log-of`. Entries are
//! appended as lines; the log is the resumption breadcrumb store ([[design]]
//! §6.1) and the narrative history of recurring completions (§4.4).
//!
//! These take `&Connection` so they compose inside a caller's transaction
//! (the recurrence roll-forward appends a completion entry within its tx).
use rusqlite::Connection;
use super::{hlc_for, links, nodes};
use crate::error::{Error, Result};
use crate::model::{LinkType, NodeKind};
/// The task's log doc id, creating (and linking) it on first use.
pub(super) fn ensure_doc(
conn: &Connection,
owner: &str,
now: i64,
task_id: &str,
) -> Result<String> {
if let Some(id) = links::first_dst(conn, task_id, LinkType::LogOf)? {
return Ok(id);
}
let task =
nodes::get(conn, task_id)?.ok_or_else(|| Error::NodeNotFound(task_id.to_string()))?;
let doc = nodes::build(
owner,
now,
NodeKind::Doc,
format!("{} — log", task.title),
Some(String::new()),
);
nodes::insert(conn, &doc)?;
links::add(conn, now, task_id, &doc.id, LinkType::LogOf)?;
Ok(doc.id)
}
/// Append `text` as a new entry (one line) to the task's log.
pub(super) fn append(
conn: &Connection,
owner: &str,
now: i64,
task_id: &str,
text: &str,
) -> Result<()> {
let doc_id = ensure_doc(conn, owner, now, task_id)?;
let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?;
let new_body = append_line(doc.body.as_deref().unwrap_or(""), text);
conn.execute(
"UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
(&new_body, now, hlc_for(now), &doc_id),
)?;
Ok(())
}
/// The task's latest `n` log entries (oldest→newest), empty if it has no log.
pub(super) fn tail(conn: &Connection, task_id: &str, n: usize) -> Result<Vec<String>> {
let Some(doc_id) = links::first_dst(conn, task_id, LinkType::LogOf)? else {
return Ok(Vec::new());
};
let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?;
let body = doc.body.unwrap_or_default();
let entries: Vec<String> = body
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect();
let start = entries.len().saturating_sub(n);
Ok(entries[start..].to_vec())
}
/// Append a single entry line to a log body, ensuring separation.
fn append_line(body: &str, text: &str) -> String {
let mut s = body.to_string();
if !s.is_empty() && !s.ends_with('\n') {
s.push('\n');
}
s.push_str(text);
s.push('\n');
s
}

View file

@ -10,6 +10,7 @@
//! delegating layer so a transaction can span several of them.
mod links;
mod log;
mod migrations;
mod nodes;
mod tasks;
@ -129,7 +130,12 @@ impl Store for LocalStore {
fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result<Task> {
let now = self.clock.now_ms();
tasks::set_state(&self.conn, now, node_id, state)
tasks::set_state(&mut self.conn, &self.owner_id, now, node_id, state)
}
fn skip_recurrence(&mut self, node_id: &str) -> Result<Task> {
let now = self.clock.now_ms();
tasks::skip(&self.conn, now, node_id)
}
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task> {
@ -154,6 +160,18 @@ impl Store for LocalStore {
fn backlinks(&self, id: &str) -> Result<Vec<Link>> {
links::backlinks(&self.conn, id)
}
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> {
let now = self.clock.now_ms();
let tx = self.conn.transaction()?;
log::append(&tx, &self.owner_id, now, task_id, text)?;
tx.commit()?;
Ok(())
}
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>> {
log::tail(&self.conn, task_id, n)
}
}
#[cfg(test)]

View file

@ -5,10 +5,11 @@
use rusqlite::{Connection, OptionalExtension, Row};
use super::{links, nodes};
use super::{hlc_for, links, log, nodes};
use crate::error::{Error, Result};
use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState};
use crate::ranking::{self, RankedTask};
use crate::recurrence;
fn from_row(row: &Row) -> rusqlite::Result<Task> {
let attention = match row.get::<_, Option<String>>("attention")? {
@ -100,20 +101,104 @@ fn require(conn: &Connection, node_id: &str) -> Result<Task> {
get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string()))
}
/// Set a task's lifecycle state.
/// Set a task's lifecycle state. Completing a **recurring** task rolls it
/// forward in place (tech-spec §4.4) rather than marking it done.
pub(super) fn set_state(
conn: &Connection,
conn: &mut Connection,
owner: &str,
now: i64,
node_id: &str,
state: TaskState,
) -> Result<Task> {
let updated = conn.execute(
let task = require(conn, node_id)?;
if state == TaskState::Done && task.recurrence.is_some() {
return roll_forward(conn, owner, now, &task);
}
conn.execute(
"UPDATE tasks SET state = ?1 WHERE node_id = ?2",
(state.as_str(), node_id),
)?;
if updated == 0 {
return Err(Error::NodeNotFound(node_id.to_string()));
nodes::touch(conn, now, node_id)?;
require(conn, node_id)
}
/// Roll a recurring task forward on completion (tech-spec §4.4): reset its
/// checklist to all-unchecked, log the occurrence, and advance the do-date to
/// the next RRULE instance strictly after `now` (skipping misses) — all in one
/// transaction. If the series is exhausted, the task is finally marked done.
fn roll_forward(conn: &mut Connection, owner: &str, now: i64, task: &Task) -> Result<Task> {
let rrule = task
.recurrence
.as_deref()
.expect("roll_forward called on a recurring task");
let tx = conn.transaction()?;
// 1. Fresh checklist — reset the canonical context doc's checkboxes.
if let Some(doc_id) = links::first_dst(&tx, &task.node_id, LinkType::CanonicalContext)? {
if let Some(doc) = nodes::get(&tx, &doc_id)? {
let body = doc.body.unwrap_or_default();
let reset = recurrence::reset_checkboxes(&body);
if reset != body {
tx.execute(
"UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4",
(&reset, now, hlc_for(now), &doc_id),
)?;
links::sync_wiki_links(&tx, owner, &doc_id, &reset, now)?;
}
}
}
// 2. Narrative history — append the completed occurrence to the log.
let entry = match task.do_date {
Some(d) => format!("- completed occurrence (do-date {d})"),
None => "- completed occurrence".to_string(),
};
log::append(&tx, owner, now, &task.node_id, &entry)?;
// 3. Advance the do-date (or finally finish a finite series).
advance(&tx, now, &task.node_id, rrule, task.do_date)?;
nodes::touch(&tx, now, &task.node_id)?;
tx.commit()?;
require(conn, &task.node_id)
}
/// Advance a recurring task to its next instance after `now`, or mark it `done`
/// if the series is exhausted. Shared by completion roll-forward and `skip`.
fn advance(
conn: &Connection,
now: i64,
node_id: &str,
rrule: &str,
do_date: Option<i64>,
) -> Result<()> {
let anchor = do_date.unwrap_or(now);
match recurrence::next_occurrence(rrule, anchor, now)? {
Some(next) => {
conn.execute(
"UPDATE tasks SET do_date = ?1, state = 'outstanding' WHERE node_id = ?2",
(next, node_id),
)?;
}
None => {
conn.execute(
"UPDATE tasks SET state = 'done' WHERE node_id = ?1",
[node_id],
)?;
}
}
Ok(())
}
/// Skip the current occurrence of a recurring task: advance the do-date the same
/// way as completion but **without** logging a completion (tech-spec §4.4).
pub(super) fn skip(conn: &Connection, now: i64, node_id: &str) -> Result<Task> {
let task = require(conn, node_id)?;
let rrule = task
.recurrence
.as_deref()
.ok_or_else(|| Error::Integrity(format!("skip on non-recurring task {node_id}")))?;
advance(conn, now, node_id, rrule, task.do_date)?;
nodes::touch(conn, now, node_id)?;
require(conn, node_id)
}

View file

@ -42,10 +42,16 @@ pub trait Store {
/// Fetch a task by its node id.
fn get_task(&self, node_id: &str) -> Result<Option<Task>>;
/// Set a task's lifecycle state. (Recurrence roll-forward is layered on in
/// a later slice — tech-spec §4.4.)
/// Set a task's lifecycle state. Completing a **recurring** task rolls it
/// forward in place — fresh checklist, logged occurrence, advanced do-date
/// (tech-spec §4.4) — rather than marking it done.
fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result<Task>;
/// Skip the current occurrence of a recurring task: advance its do-date
/// without logging a completion (tech-spec §4.4). Errors on a non-recurring
/// task.
fn skip_recurrence(&mut self, node_id: &str) -> Result<Task>;
/// Set a task's attention-state.
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>;
@ -64,4 +70,13 @@ pub trait Store {
/// All non-tombstoned links pointing at `id` (backlinks).
fn backlinks(&self, id: &str) -> Result<Vec<Link>>;
// --- per-task log ([[design]] §6.4) ---
/// Append a line to a task's append-only log (creating the log on first
/// use). The log is the resumption breadcrumb store.
fn log_append(&mut self, task_id: &str, text: &str) -> Result<()>;
/// The task's latest `n` log entries (oldest→newest); empty if it has none.
fn log_tail(&self, task_id: &str, n: usize) -> Result<Vec<String>>;
}

View file

@ -0,0 +1,175 @@
//! End-to-end recurrence: completing a recurring task rolls it forward in
//! place — fresh checklist, logged occurrence, advanced do-date, never
//! carrying completion forward (tech-spec §4.4, slice 5).
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
use heph_core::{Clock, LinkType, LocalStore, NewTask, Store, TaskState};
const JAN1: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
const ONE_DAY: i64 = 86_400_000;
/// A clock the test can advance between completions. Cloneable so the test
/// keeps a handle while the store owns its own `Box<dyn Clock>`.
#[derive(Clone)]
struct StepClock(Arc<AtomicI64>);
impl StepClock {
fn new(ms: i64) -> Self {
StepClock(Arc::new(AtomicI64::new(ms)))
}
fn set(&self, ms: i64) {
self.0.store(ms, Ordering::SeqCst);
}
}
impl Clock for StepClock {
fn now_ms(&self) -> i64 {
self.0.load(Ordering::SeqCst)
}
}
fn store_at(ms: i64) -> (LocalStore, StepClock) {
let clock = StepClock::new(ms);
let store = LocalStore::open_in_memory(Box::new(clock.clone())).unwrap();
(store, clock)
}
fn canonical_doc_id(s: &LocalStore, task_id: &str) -> String {
s.outgoing_links(task_id)
.unwrap()
.into_iter()
.find(|l| l.link_type == LinkType::CanonicalContext)
.unwrap()
.dst_id
}
#[test]
fn completing_a_recurring_task_rolls_forward_with_a_fresh_checklist() {
let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2);
let task = s
.create_task(NewTask {
title: "Morning routine".into(),
do_date: Some(JAN1),
recurrence: Some("FREQ=DAILY".into()),
..Default::default()
})
.unwrap();
let doc_id = canonical_doc_id(&s, &task.node_id);
// Put a checklist in the context doc and check items off.
s.update_node(
&doc_id,
None,
Some("- [x] brush teeth\n- [x] feed birds\n- [ ] coffee\n".into()),
)
.unwrap();
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
// It stays outstanding and the do-date advanced to the next day.
assert_eq!(rolled.state, TaskState::Outstanding);
assert_eq!(rolled.do_date, Some(JAN1 + ONE_DAY));
// The checklist is fresh — every item unchecked. Completion did NOT carry.
let doc = s.get_node(&doc_id).unwrap().unwrap();
assert_eq!(
doc.body.as_deref(),
Some("- [ ] brush teeth\n- [ ] feed birds\n- [ ] coffee\n")
);
// The completion was logged (the narrative breadcrumb).
let log = s.log_tail(&task.node_id, 10).unwrap();
assert_eq!(log.len(), 1);
assert!(log[0].contains("completed occurrence"));
}
#[test]
fn missed_occurrences_collapse_to_one_not_a_pile() {
// Complete 3.5 days late → next instance is day 4, not a backlog of 4.
let (mut s, _clock) = store_at(JAN1 + 3 * ONE_DAY + ONE_DAY / 2);
let task = s
.create_task(NewTask {
title: "Water plants".into(),
do_date: Some(JAN1),
recurrence: Some("FREQ=DAILY".into()),
..Default::default()
})
.unwrap();
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
assert_eq!(rolled.do_date, Some(JAN1 + 4 * ONE_DAY));
assert_eq!(rolled.state, TaskState::Outstanding);
}
#[test]
fn completion_never_carries_forward_across_many_occurrences() {
let (mut s, clock) = store_at(JAN1 + ONE_DAY / 2);
let task = s
.create_task(NewTask {
title: "Daily standup notes".into(),
do_date: Some(JAN1),
recurrence: Some("FREQ=DAILY".into()),
..Default::default()
})
.unwrap();
let doc_id = canonical_doc_id(&s, &task.node_id);
for occurrence in 0..5 {
// Check everything off this occurrence.
s.update_node(&doc_id, None, Some("- [x] note A\n- [x] note B\n".into()))
.unwrap();
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
assert_eq!(rolled.state, TaskState::Outstanding);
// Fresh checklist after every single completion.
let doc = s.get_node(&doc_id).unwrap().unwrap();
assert_eq!(
doc.body.as_deref(),
Some("- [ ] note A\n- [ ] note B\n"),
"occurrence {occurrence} did not present a fresh checklist"
);
// Advance "now" past the new do-date for the next loop.
clock.set(rolled.do_date.unwrap() + ONE_DAY / 2);
}
// Five completions → five log entries.
assert_eq!(s.log_tail(&task.node_id, 100).unwrap().len(), 5);
}
#[test]
fn skip_advances_without_logging() {
let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2);
let task = s
.create_task(NewTask {
title: "Optional chore".into(),
do_date: Some(JAN1),
recurrence: Some("FREQ=DAILY".into()),
..Default::default()
})
.unwrap();
let skipped = s.skip_recurrence(&task.node_id).unwrap();
assert_eq!(skipped.do_date, Some(JAN1 + ONE_DAY));
assert_eq!(skipped.state, TaskState::Outstanding);
assert!(s.log_tail(&task.node_id, 10).unwrap().is_empty());
}
#[test]
fn non_recurring_task_done_is_just_done() {
let (mut s, _clock) = store_at(JAN1);
let task = s
.create_task(NewTask {
title: "One-off".into(),
..Default::default()
})
.unwrap();
let t = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
assert_eq!(t.state, TaskState::Done);
}

View file

@ -4,4 +4,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks.
- Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent).
- "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite.
- Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging.
- CI runs the Rust suite (fmt/clippy/test) via the project build hook.