feat(core): task.set_schedule — reschedule do-date/late-on/recurrence
Some checks failed
Build / validate (pull_request) Has been cancelled

There was no way to change a task's do-date, late-on, or recurrence after
creation (only attention/state had setters) — a real reschedule gap. Add a
single patch method covering the three schedule scalars with no setter.

- model: SchedulePatch with double-option fields (absent=leave, null=clear,
  value=set), serde-skips absent fields so the distinction round-trips
- Store::set_task_schedule + LocalStore/RemoteStore impls; sqlite set_schedule
  overlays present fields then records the LWW task.set op (sync-correct)
- rpc dispatch: task.set_schedule (id + flattened patch)
- tests: core set/clear/leave + missing-task; rpc_socket round-trip asserting
  the absent/null/value semantics over the wire

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-02 19:26:25 -07:00
commit 70d5af5bdc
9 changed files with 213 additions and 10 deletions

View file

@ -28,7 +28,7 @@ pub use extract::{extract, ContextItem, Extraction};
pub use hlc::{Hlc, HlcClock};
pub use model::{
deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node,
NodeKind, SyncCursors, Task, TaskState,
NodeKind, SchedulePatch, SyncCursors, Task, TaskState,
};
pub use oplog::Op;
pub use ranking::{rank, Dimension, RankedTask, RANKING};

View file

@ -248,6 +248,56 @@ pub struct NewTask {
pub project_id: Option<String>,
}
/// A partial update to a task's schedule scalars (tech-spec §6 `task.set_schedule`).
///
/// Each field is a **double option** so the three states are distinct: absent
/// (`None`) = leave unchanged; present-`null` (`Some(None)`) = clear; present
/// value (`Some(Some(v))`) = set. Attention has its own setter; project edits
/// are link add/remove — this struct covers the scalars with no setter today
/// (the "reschedule" gap).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SchedulePatch {
/// Earliest-actionable date, epoch ms (candidacy gate only, §7).
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "double_option"
)]
pub do_date: Option<Option<i64>>,
/// Lateness-problem marker, epoch ms (the sole urgency signal, §7).
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "double_option"
)]
pub late_on: Option<Option<i64>>,
/// RRULE for a recurring definition (§4.4); clear to make non-recurring.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "double_option"
)]
pub recurrence: Option<Option<String>>,
}
impl SchedulePatch {
/// True when the patch touches nothing (every field absent).
pub fn is_empty(&self) -> bool {
self.do_date.is_none() && self.late_on.is_none() && self.recurrence.is_none()
}
}
/// Deserialize a present field (even an explicit `null`) into `Some(...)`, so a
/// missing key (via `#[serde(default)]`) stays `None`. This is what makes the
/// [`SchedulePatch`] double-option distinguish "absent" from "null".
fn double_option<'de, T, D>(deserializer: D) -> std::result::Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Deserialize::deserialize(deserializer).map(Some)
}
/// Working-set health — the §6.2 tensions, surfaced honestly (tech-spec §7).
/// Never masks overload nor manufactures calm.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -30,8 +30,8 @@ use crate::clock::Clock;
use crate::error::{Error, Result};
use crate::hlc::Hlc;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task,
TaskState,
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch,
SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
use crate::ranking::RankedTask;
@ -231,6 +231,11 @@ impl Store for LocalStore {
tasks::set_attention(&self.conn, &self.owner_id, now, node_id, attention)
}
fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task> {
let now = self.clock.now_ms();
tasks::set_schedule(&self.conn, &self.owner_id, now, node_id, patch)
}
fn promote(
&mut self,
container_id: &str,

View file

@ -10,7 +10,9 @@ use serde_json::json;
use super::{links, log, next_hlc, nodes, ops};
use crate::error::{Error, Result};
use crate::extract;
use crate::model::{Attention, Health, LinkType, NewTask, NodeKind, Task, TaskState};
use crate::model::{
Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState,
};
use crate::oplog::op_type;
use crate::ranking::{self, RankedTask};
use crate::recurrence;
@ -496,3 +498,32 @@ pub(super) fn set_attention(
record_set(conn, owner, now, node_id)?;
require(conn, node_id)
}
/// Apply a partial schedule update (do-date / late-on / recurrence) — the
/// "reschedule" path (tech-spec §6). Reads the current row, overlays the
/// present `patch` fields (a double-option per field: absent = leave, `null` =
/// clear, value = set), writes all three columns, and records the LWW op.
pub(super) fn set_schedule(
conn: &Connection,
owner: &str,
now: i64,
node_id: &str,
patch: SchedulePatch,
) -> Result<Task> {
let mut task = require(conn, node_id)?;
if let Some(v) = patch.do_date {
task.do_date = v;
}
if let Some(v) = patch.late_on {
task.late_on = v;
}
if let Some(v) = patch.recurrence {
task.recurrence = v;
}
conn.execute(
"UPDATE tasks SET do_date = ?1, late_on = ?2, recurrence = ?3 WHERE node_id = ?4",
(&task.do_date, &task.late_on, &task.recurrence, node_id),
)?;
record_set(conn, owner, now, node_id)?;
require(conn, node_id)
}

View file

@ -6,8 +6,8 @@
use crate::error::Result;
use crate::model::{
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SyncCursors, Task,
TaskState,
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch,
SyncCursors, Task, TaskState,
};
use crate::oplog::Op;
use crate::ranking::RankedTask;
@ -71,6 +71,12 @@ pub trait Store {
/// Set a task's attention-state.
fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result<Task>;
/// Apply a partial update to a task's schedule scalars — do-date, late-on,
/// recurrence (tech-spec §6 `task.set_schedule`). Each [`SchedulePatch`]
/// field is a double option: absent = unchanged, `null` = clear, value =
/// set. This is the "reschedule" path (the scalars with no dedicated setter).
fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task>;
/// Promote a `- [ ]` context-item line in `container_id`'s body into a
/// committed task, rewriting that source line into a `[[link]]` to the new
/// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the

View file

@ -2,13 +2,80 @@
//! wiki-link materialization (tech-spec §4§6, slice 3).
use heph_core::{
Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store, TaskState,
Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, SchedulePatch, Store,
TaskState,
};
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
}
#[test]
fn set_schedule_sets_clears_and_leaves_fields_per_double_option() {
let mut s = store();
let task = s
.create_task(NewTask {
title: "Renew passport".into(),
do_date: Some(1_000),
late_on: Some(2_000),
recurrence: Some("FREQ=YEARLY".into()),
..Default::default()
})
.unwrap();
let id = task.node_id;
// Set do_date, clear recurrence, leave late_on untouched (absent).
let updated = s
.set_task_schedule(
&id,
SchedulePatch {
do_date: Some(Some(5_000)),
recurrence: Some(None),
..Default::default()
},
)
.unwrap();
assert_eq!(updated.do_date, Some(5_000), "do_date set");
assert_eq!(updated.late_on, Some(2_000), "late_on left unchanged");
assert_eq!(updated.recurrence, None, "recurrence cleared");
// An empty patch is a no-op (leaves everything).
let same = s.set_task_schedule(&id, SchedulePatch::default()).unwrap();
assert_eq!(same.do_date, Some(5_000));
assert_eq!(same.late_on, Some(2_000));
// Clearing do_date makes the task always-actionable again.
let cleared = s
.set_task_schedule(
&id,
SchedulePatch {
do_date: Some(None),
..Default::default()
},
)
.unwrap();
assert_eq!(cleared.do_date, None);
// Adding a recurrence back makes it a recurring definition.
let recurring = s
.set_task_schedule(
&id,
SchedulePatch {
recurrence: Some(Some("FREQ=WEEKLY".into())),
..Default::default()
},
)
.unwrap();
assert_eq!(recurring.recurrence.as_deref(), Some("FREQ=WEEKLY"));
}
#[test]
fn set_schedule_on_a_missing_task_is_not_found() {
let mut s = store();
let err = s.set_task_schedule("nope", SchedulePatch::default());
assert!(err.is_err(), "expected NodeNotFound, got {err:?}");
}
#[test]
fn promote_mints_a_task_and_rewrites_the_line_into_a_link() {
let mut s = store();

View file

@ -17,8 +17,8 @@ use serde::de::DeserializeOwned;
use serde_json::{json, Value};
use heph_core::{
Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, Store,
SyncCursors, Task, TaskState,
Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result,
SchedulePatch, Store, SyncCursors, Task, TaskState,
};
use crate::oauth::{self, TokenStore};
@ -160,6 +160,13 @@ impl Store for RemoteStore {
)
}
fn set_task_schedule(&mut self, node_id: &str, patch: SchedulePatch) -> Result<Task> {
// Serialize the patch (absent fields are skipped), then inject `id`.
let mut params = serde_json::to_value(&patch).expect("SchedulePatch serializes");
params["id"] = json!(node_id);
self.call_as("task.set_schedule", params)
}
fn promote(
&mut self,
container_id: &str,

View file

@ -13,7 +13,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use heph_core::{Attention, LinkType, NewNode, NewTask, Store, TaskState};
use heph_core::{Attention, LinkType, NewNode, NewTask, SchedulePatch, Store, TaskState};
/// A JSON-RPC request line.
#[derive(Debug, Deserialize)]
@ -261,6 +261,13 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
let p: SetAttentionParams = parse(params)?;
json!(store.set_task_attention(&p.id, p.attention)?)
}
"task.set_schedule" => {
// `id` + the flattened `SchedulePatch` arrive in one object; parse
// the id, then the patch (which ignores the extra `id` key).
let id: IdParam = parse(params.clone())?;
let patch: SchedulePatch = parse(params)?;
json!(store.set_task_schedule(&id.id, patch)?)
}
"task.skip" => {
let p: IdParam = parse(params)?;
json!(store.skip_recurrence(&p.id)?)

View file

@ -134,6 +134,36 @@ fn task_create_appears_in_next_with_context_link() {
assert_eq!(doc["kind"], "doc");
}
#[test]
fn task_set_schedule_patches_over_socket() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let task = c
.call(
"task.create",
json!({ "title": "Renew passport", "do_date": 1000, "late_on": 2000, "recurrence": "FREQ=YEARLY" }),
)
.unwrap();
let id = task["node_id"].as_str().unwrap().to_string();
// Present value sets, explicit null clears, absent field is left alone.
let updated = c
.call(
"task.set_schedule",
json!({ "id": id, "do_date": 5000, "recurrence": null }),
)
.unwrap();
assert_eq!(updated["do_date"], 5000, "do_date set");
assert_eq!(updated["late_on"], 2000, "late_on untouched (absent)");
assert!(updated["recurrence"].is_null(), "recurrence cleared");
// The change is durable.
let got = c.call("task.get", json!({ "id": id })).unwrap();
assert_eq!(got["do_date"], 5000);
assert!(got["recurrence"].is_null());
}
#[test]
fn promote_context_item_over_socket() {
let (socket, _dir) = spawn_daemon();