feat(api): interruption fact logging and schedule replanning #69
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-26-replan"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Real execution is interrupted. A production incident, an impromptu meeting or an
urgent obligation takes time out of a schedule that was already planned, and a
service that answers that badly does one of two things: it leaves the blocks in
the past as if they happened, or it silently moves them on the remote calendar.
Life separates the two halves structurally — a timeline fact about what
happened, and a reviewable proposal about what to do next — and #26 implements
the interruption workflow (
docs/technical/workflows.md#planning-and-interruption,scenario W08): "Record user-reported interruption separately; compute available
remainder; review changed commitments", with its failure path "Recording may
succeed while proposal generation fails; report both, do not erase fact or claim
calendar changed".
Changes
The fact is recorded first, and independently (
src/domain/timeline/interruption.rs,src/history/journal.rs)POST /v1/replanstakes{interruption: {item_ids, occurred_at?, ended_at?, reason?}, remaining_intended_outcomes?, policy_exceptions?}and appends onelife_eventsrow with
kind = "interruption_logged"andevidence_class = "user_report"—never
confirmed_service— carrying the affected item ids, the actual start theuser reported, a nullable end, and the reason when supplied.
occurred_atis recorded asnullbeside therecorded_atthereport was received at, and an unknown end stays unknown: neither is inferred
from "now", from the requested window or from the moment of the report. A
date-only value is refused rather than resolved to a midnight instant.
JournalService::record_fact_operationwrites theoperationsrow (applied,since a local-only fact has no second network phase) and appends the fact in one
local transaction, with the replay/conflict semantics every other operation key
has. The reasoning mirrors
src/history/proposals.rs: recording itpreparedwould make startup recovery treat a local fact as an in-flight remote write.
Generation follows, and cannot erase the fact (
src/api/replans.rs,src/history/proposals.rs)be read, the capacity is unknown, a named outcome does not resolve, a policy cap
has no explicit exception — the answer is
status: partial(HTTP 200, CLI exit6) with the recorded interruption in
data,proposal: null, and the failure inerror. The calendar is never claimed to have changed.record_generation_outcomestores the outcome in a second transaction: theproposal row when one was generated, or the failure when none was — and the
operation's recorded answer either way, so a replay reports what the first
attempt actually answered. If a process dies between the two writes, a replay
says so (
operation_unknown) instead of inventing a proposal.Replanning is planning from a later instant (
src/api/plans.rs,src/domain/policy/capacity.rs)generatenow takes aGeneration(the window to observe, the named outcomes, the exceptions) and bothroutes call it. The remaining window is the remainder of the anchor's local day
(
remaining_day), whichresolve_working_windowsalready clamps to the anchor —so the same working windows, the same 40% reserve on what remains, the same caps
and the same ordered-step builder apply. A past end does not move the anchor
backwards; an unknown end anchors at the report and is disclosed as
unknown_estimatewithanchor.basis = "reported_now".pending, 24-hourexpiry. The previous proposal's row is neither read nor mutated.
Zero calendar writes — the route holds no writing CalDAV client. One
operationsrow, onelife_eventsfact and oneproposalsrow are the wholeeffect;
item_projection,sync_stateandoutboxstay untouched. The calendarchanges only when #27 applies a reviewed proposal.
Timeline queryability (
src/api/timeline.rs,src/history/timeline.rs)GET /v1/timelineread (life timeline=GET /v1/timeline, read): the last seven local days byrecorded_atby default,with
from/to,item_id,evidence_class,occurred_from/to,limitandcursor. A fact's recording time and its nullable actual time are kept apart,and every unknown member is serialized as
nullrather than omitted, so anunknown end is visible as unknown rather than absent. The view reports how many
facts carry no actual time, which is what tells a quiet period from an
unobserved one.
CLI —
life replan --input - [--operation-id KEY] [--json]prints onestandard envelope and exits 0/2/4/5/6 per the contract. The payload is validated
before the daemon is contacted, so a malformed report is exit 2 without a round
trip. The
tests/cli_tests.rsunimplemented-command fixture moved fromreplantoreview(#30), and thetests/api_tests.rs501-stub fixture moved from/v1/timelineto/v1/reviews, so the coverage survives.Verification
nix develop --command cargo test— 633 passed, 0 failed (607 before thischange; the new coverage is
tests/replan_tests.rswith 17 integration tests plus8 unit tests in the interruption module).
nix develop --command cargo clippy --all-targets -- -D warnings— clean.nix develop --command cargo fmt --check— clean.tests/replan_tests.rsdrives the real route and the real binary against a mockCalDAV server over TCP and the daemon's own SQLite worker, asserting:
user_reportevidence class, actor, source, affectedids, reason, dedupe key and operation linkage;
nullwith theunknown_estimatewarning andanchor.basis = "reported_now";dependency_unavailable; unresolvable outcome →not_found):status: partial,the fact present and durable, zero proposal rows, and the recorded answer
replayable;
13:00–14:00 hour: gross 300, busy 60, remaining 240, reserved 96 (40%),
allocatable 144 — plus the two ordered steps at 14:00–15:30 and the stored digest
recomputed from the stored steps;
REPORT, and thejournal deltas are exactly
+1operations,+1life_events,+1proposals and0everywhere else;versus
idempotency_conflict(409) for a changed payload;GET /v1/timeline, withoccurred_atdistinct fromrecorded_at,unknown_time_countand anullend visible,view=correctedrefused 501 and an unknown
evidence_classrefused 400;life servedaemon and astand-in Radicale:
life replan --input - --jsonexited 0 withgross 300 / busy 60 / remaining 240 / reserved 96 / allocatable 144, apendingproposal and itsdigest; the CalDAV log contained six
REPORTs and no other method; the journal heldone fact per report and one proposal per generated outcome. With the calendar
unreachable, the same command exited 6 with
status: partial, the interruptionevent id and
proposal: null, while the fact was durable inlife_eventsandGET /v1/timelinereturned it.Notes, and what #27 needs
proposal.proposal_id, its canonicaldigest,state: pending,expires_atandthe ordered steps with their
baseline_etags. Nothing here applies anything; theproposal becomes a commitment only when
life proposal applyruns.StepActionvocabulary (update_task,create_event)instead of introducing
update_event/cancel_eventsteps: the #25 builder and#27's preflight are built on that vocabulary, and a second one here would be a
second planner with no apply semantics behind it.
incomplete_calendarwhen the end is unknown.Only
unknown_estimateis emitted:incomplete_calendarmeans a collectioncould not be read, and emitting it for a known calendar would be a false
statement. An unknown end still allocates nothing confidently when the calendar
is unreadable, because that is the capacity rule, not a warning.
GET /v1/timelinereports nocoveragearray: the journal is a single localstore, so a successful read is complete by construction and a failed read is an
error rather than a partial view.
view=correctedis refused withnot_implemented(corrections are #29) rather than answered with raw facts undera corrected name. The
life timelineCLI verb is left unimplemented, as #26'sCLI scope is
life replan.data.proposal.steps[].action = "update_event"exampleis not the shape produced here, for the reason above.
Related Issues
Closes #26
Coordinator review — APPROVED
Verified
6ed44f4myself:cargo test633 passed / 0 failed (17 new replan tests),cargo clippy --all-targets -- -D warnings0 errors,cargo fmt --checkclean.What I checked beyond the summary:
journal::record_fact_operationcommits theoperationsrow and thelife_eventsappend in one transaction, with replay detection by fingerprint (a mismatched fingerprint on an existing operation id is an error rather than a silent overwrite). That is the right shape for #28/#29 to reuse, and it means "fact first" is a transaction boundary rather than a call ordering you have to trust.remaining_intended_outcomes— and in both cases the fact survives withstatus: partial, the interruption event id present,proposal: null,calDAV.read_only(), and exact row deltas. The manual smoke confirms the same on a real daemon (exit 6, durable row,operations.state=applied, zero proposals). Reporting both outcomes separately is the criterion's whole point, and it is demonstrated rather than asserted.user_report, and the tests assert it is notconfirmed_service— the distinction that keeps an unverified claim from inheriting a service-confirmed one's evidential strength. An unknown end is stored asended_at: nullrather than being filled from "now".plans::generateis now shared) with the anchor's remaining local day: gross 300, busy 60, remaining 240, reserved 96 (ceil, 40% of remaining), allocatable 144 — the same rule as #25, applied to a later instant, and the stored digest recomputes from the stored steps.read_only()(every request the daemon made begins withREPORT) plus per-table deltas, and the smoke run's CalDAV loguniq -cshowing sixREPORTs and nothing else.tests/cli_tests.rsunimplemented-command fixture was moved to a still-unimplemented verb rather than deleted, keeping that coverage alive as the CLI fills in.Merging.
#27now needs only the new proposal id and digest plus preflight, which the PR states explicitly.feat(api): interruption fact logging and schedule replanning (#26)to feat(api): interruption fact logging and schedule replanning