feat(api): agenda and tasks list cli api with completeness metrics #44
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-9-agenda-tasks"
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
Issue #009 delivers the user-facing read surface over the live Radicale state:
life agenda,life tasks list, andlife item getwith their API counterparts. Every answer is a current backend read with per-collection coverage, so a failed or uninterpretable collection degrades the response explicitly (partial+incomplete_calendar) instead of ever claiming an empty calendar. This is the read foundation that the mutation and planning issues build on.Changes
src/api/mod.rs— read-route module: request-id middleware,ApiFailure→ HTTP status/CLI exit mapping (ok 0,partial 6,invalid_input 2,not_found 1,conflict 4,dependency_unavailable 5,cursor_expired422/2), routes/v1/agenda,/v1/tasks,/v1/items/{id}.src/api/agenda.rs—GET /v1/agenda:period(today/tomorrow/week) orfrom/toresolved to local midnight in the configured IANA zone, optionalcollection_ids, concurrent per-collection bounded interval REPORTs, events sorted by start and deadlines by urgency (date-only deadlines weigh at the end of their local day while staying tagged as dates).src/api/tasks.rs—GET /v1/tasks: open-by-default filter (NEEDS-ACTION,IN-PROCESS, unspecified and unknown non-terminal external statuses kept raw;COMPLETED/CANCELLEDexcluded),strength/area/due_beforefilters, undated tasks always retained, deadline-first deterministic ordering.src/api/items.rs—GET /v1/items/{id}: the opaque id is one-way, so resolution is a live scan of every collection with the id recomputed per component; the matched resource is then re-read live for the current ETag and fulldescription. Bounded live re-reads disclose ETag instability ascoverage: concurrent_changewithcomplete: falseand aconcurrent_changewarning rather than claiming an atomic snapshot. Live absence is looked up in durablelife_eventshistory through the daemon's shared worker and returnsnot_found(404 / exit 1) with that historical context; when the journal cannot be read, the failure is disclosed as ahistory_unavailablewarning (and named in the message) instead of being collapsed into a bare claim of non-existence. Removal-kind detection is an explicit contract list (empty today:life_events.kindis free-formTEXTand the removal vocabulary lands with the mutation issues), so an unrecognized kind is reported as not a known removal kind rather than guessed from its spelling.src/api/mod.rs—AppStateowns the daemon's singleArc<DatabaseWorker>(one SQLite connection, one blocking thread), started once at daemon startup inapi::serveand reused by every request; a failed startup is logged and read routes then disclosehistory_unavailable.ApiFailurecan carry warnings so an error response still discloses what degraded.src/api/types.rs— item shape with kind-irrelevant fields omitted,consumes_capacity(transparent and cancelled events never consume capacity; opaque/tentative conservatively do; tasks never do), coverage/limits types,item_id_for(itm_+ base32 SHA-256 ofcollection|uid|recurrence).src/api/pagination.rs— opaque HMAC-signed cursors bound to the normalized filter fingerprint and a 15-minute expiry; default limit 50, clamped at 200; expired, tampered or filter-mismatched cursors yieldcursor_expired.src/client.rs— CLI client: endpoint resolution from--server-url/LIFE_SERVER_URL,--token/LIFE_TOKENand the client config ([service] url+token_file,timeout_seconds), bearer auth, single JSON envelope on stdout, and exit-code mapping including transport timeout → exit 6 with the locally generatedX-Request-ID.src/cli.rs—life agenda,life tasks list,life item get ID:--jsonenvelopes plus aligned terminal tables (agendaTIME/KIND/TITLE/COLLECTION/CAPACITY, tasksID/STATUS/STRENGTH/DEADLINE/TITLE/ESTIMATE, item metadata/description/ETag/coverage) and a warning banner whenevercomplete == false.src/calendar/client.rs— shared DAV transport now exposes response status and ETag and adds a live resourceGET;Ok(None)distinguishes live absence (404/410) from transport failure.src/calendar/report.rs— inventory query builder generalized to arbitrary component filters without a time-range (undated VTODOs and out-of-window VEVENTs included); todo-only builder now delegates to it.tests/agenda_tasks_tests.rs— new integration suite over a mock CalDAV backend that serves REPORT inventories and live resource GETs with ETags.Verification
cargo test— 97 tests across 9 suites, all passing.tests/agenda_tasks_tests.rs(20 tests) covers scenario W03 (agenda window bounds in the configured zone, deadlines distinct from events, date-only deadline keeps its date representation), W04 (open filter with undated inclusion, unknown status kept raw,completed/allfilters), C11 (transparent event visible but non-blocking; opaque tentative consumes capacity), C13 (unparseable calendar-data degrades tounsupported, never a complete read with zero items), C14 (ETag drift across bounded live reads →concurrent_change+complete: false), C01 (one collection down →partial+incomplete_calendar; all down →dependency_unavailable, never empty), pagination invariants (limit clamped at 200, cursor round-trip, expired/tampered/filter-mismatched cursors → 422cursor_expired),collection_idsfiltering and rejection of unknown collection ids, item get (live ETag + full description returned only on item get, unknown id → 404not_found, malformed id →invalid_input, all collections down →dependency_unavailable, durable-history context on live absence), and a CLI end-to-end run against a real daemon (life item get→ exit 0, absent id → exit 1, malformed id → exit 2).test_item_get_uses_shared_state_worker: history lives in a database that is deliberately not the configured storage path, and the configured path is never opened), and that an unreadable journal is disclosed rather than turned into a barenot_found(test_item_get_discloses_unavailable_history_instead_of_claiming_absence: no worker at all, and a worker whoselife_eventstable was dropped, both yieldhistory_unavailablewith the underlying reason in the warning).life servecreates the SQLite database at boot (single worker started before any request),/health/liveanswers, and the item route is live (malformed id → 400).cargo clippy --all-targets -- -D warningsandcargo fmt --checkare clean.Known deviations
docs/technical/cli-api.md. Ordering is deterministic, so paging is stable for an unchanged backend, but cross-page drift (a skipped or repeated item) is possible under concurrent external edits. The daemon-side materialized page store (bounded session state, eviction, restart semantics) is deliberately deferred to a follow-up issue tracked by the coordinator; the issue's pagination acceptance criteria (default 50, clamp 200, opaque cursor,cursor_expired) are fully met.Related Issues
Closes #9
Coordinator review — CHANGES REQUESTED
Reviewed the full diff at
f885b4cagainstdocs/issues/009-agenda-tasks-list-cli-api.mdanddocs/technical/{cli-api,calendar,architecture,timeline-consistency}.md. The read surface is genuinely solid: W03/W04/C11/C13/C14/C01 all have real tests, capacity semantics are correct (consumed_capacityfalse only for transparent/cancelled), cursors are HMAC-signed + filter-bound + expiring, item inspection re-reads live, and CLI exit-code mapping matches the contract.Three changes needed before merge:
1. (major) Spawns a database worker per request.
src/api/items.rs::history_absencecallsDatabaseWorker::start(&config.storage.database_path)on everyGET /v1/items/{id}. That opens a new SQLite connection, a new OS thread namedsqlite-worker, and re-runs the baseline migrations per request, andlife servenever starts a worker at all (src/cli.rsserve arm only callsapi::serve).docs/technical/architecture.mdrequires one dedicated blocking database worker owning the connection — please start a single worker at daemon startup, hold it inAppState(e.g.Arc<DatabaseWorker>), and reuse it. Every later issue (#10–#12) needs this handle inAppStateanyway, so this is the right cut point.2. (major) Absence is asserted on silently-swallowed evidence.
history_absenceusesDatabaseWorker::start(...).ok()?,worker.query(...).await.ok()?androw.get_str(...).unwrap_or(""), so any journal failure degrades to "no history record" and the route then returns a barenot_found. That is precisely the false claim the spec forbids ("If not found in history or projection, returns not_found" presumes the lookup actually ran). Distinguish the two cases: history read failure must be disclosed (warning e.g.history_unavailable, orpartial), never collapsed into "does not exist".3. (minor) Removal-kind detection guesses.
is_removal_kindsubstring-matches"delete"/"remove"overlife_events.kind, and no event kinds exist yet, so this silently mis-classifies once the real kind vocabulary lands. Make it an explicit match against the documented kinds (or leave a clearly-markedponytail:-style note naming the contract it must be extended from) instead of fuzzy matching.Not blocking, tracked separately: the pagination materialized-read deviation is accepted as documented (follow-up issue filed), and server-side bearer-token enforcement is issue #35, not this one.
Coordinator re-review — APPROVED
Verified the review-action commit
1d48e50myself (read the diff, ran the suite):AppState.history: Option<Arc<DatabaseWorker>>, started exactly once inapi::servefromconfig.storage.database_path;run_item_get(&config, state.history.as_deref(), ..)reuses it. No per-request worker remains. Panic-free startup: a failed worker is logged and routes then disclosehistory_unavailablerather than silently degrading.HistoryLookup::{Found, NoRecord, Unavailable}; a failed or malformed history read producesnot_foundwith ahistory_unavailablewarning (ApiFailure.warningssurfaced througherror_response, and the CLI now prints warnings on error envelopes). Absence is never asserted on a lookup that did not run.REMOVAL_EVENT_KINDScontract list with the doc comment naming where the vocabulary will come from (#012/#015); unrecognized kinds are quoted, never guessed.Independent verification on
1d48e50:cargo test99 passed / 0 failed across 9 suites;cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean.Merging.
feat(api): agenda and tasks list cli api with completeness metrics (#9)to feat(api): agenda and tasks list cli api with completeness metrics