feat(api): agenda and tasks list cli api with completeness metrics #44

Merged
yuris merged 2 commits from feat/issue-9-agenda-tasks into main 2026-09-22 20:45:44 +00:00
Owner

Motivation

Issue #009 delivers the user-facing read surface over the live Radicale state: life agenda, life tasks list, and life item get with 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_expired 422/2), routes /v1/agenda, /v1/tasks, /v1/items/{id}.
  • src/api/agenda.rs — GET /v1/agenda: period (today/tomorrow/week) or from/to resolved to local midnight in the configured IANA zone, optional collection_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/CANCELLED excluded), strength/area/due_before filters, 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 full description. Bounded live re-reads disclose ETag instability as coverage: concurrent_change with complete: false and a concurrent_change warning rather than claiming an atomic snapshot. Live absence is looked up in durable life_events history through the daemon's shared worker and returns not_found (404 / exit 1) with that historical context; when the journal cannot be read, the failure is disclosed as a history_unavailable warning (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.kind is free-form TEXT and 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 — AppState owns the daemon's single Arc<DatabaseWorker> (one SQLite connection, one blocking thread), started once at daemon startup in api::serve and reused by every request; a failed startup is logged and read routes then disclose history_unavailable. ApiFailure can 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 of collection|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 yield cursor_expired.
  • src/client.rs — CLI client: endpoint resolution from --server-url/LIFE_SERVER_URL, --token/LIFE_TOKEN and 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 generated X-Request-ID.
  • src/cli.rs — life agenda, life tasks list, life item get ID: --json envelopes plus aligned terminal tables (agenda TIME/KIND/TITLE/COLLECTION/CAPACITY, tasks ID/STATUS/STRENGTH/DEADLINE/TITLE/ESTIMATE, item metadata/description/ETag/coverage) and a warning banner whenever complete == false.
  • src/calendar/client.rs — shared DAV transport now exposes response status and ETag and adds a live resource GET; 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/all filters), C11 (transparent event visible but non-blocking; opaque tentative consumes capacity), C13 (unparseable calendar-data degrades to unsupported, 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 → 422 cursor_expired), collection_ids filtering and rejection of unknown collection ids, item get (live ETag + full description returned only on item get, unknown id → 404 not_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).
  • Item get additionally proves it reuses the daemon's worker (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 bare not_found (test_item_get_discloses_unavailable_history_instead_of_claiming_absence: no worker at all, and a worker whose life_events table was dropped, both yield history_unavailable with the underlying reason in the warning).
  • Daemon startup smoke: life serve creates the SQLite database at boot (single worker started before any request), /health/live answers, and the item route is live (malformed id → 400).
  • cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean.

Known deviations

  • Pagination re-queries the backend live for each page instead of sharing one bounded materialized read result as described in 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.

Closes #9

## Motivation Issue #009 delivers the user-facing read surface over the live Radicale state: `life agenda`, `life tasks list`, and `life item get` with 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_expired` 422/2), routes `/v1/agenda`, `/v1/tasks`, `/v1/items/{id}`. - `src/api/agenda.rs` — `GET /v1/agenda`: `period` (`today`/`tomorrow`/`week`) or `from`/`to` resolved to local midnight in the configured IANA zone, optional `collection_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`/`CANCELLED` excluded), `strength`/`area`/`due_before` filters, 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 full `description`. Bounded live re-reads disclose ETag instability as `coverage: concurrent_change` with `complete: false` and a `concurrent_change` warning rather than claiming an atomic snapshot. Live absence is looked up in durable `life_events` history through the daemon's shared worker and returns `not_found` (404 / exit 1) with that historical context; when the journal cannot be read, the failure is disclosed as a `history_unavailable` warning (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.kind` is free-form `TEXT` and 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` — `AppState` owns the daemon's single `Arc<DatabaseWorker>` (one SQLite connection, one blocking thread), started once at daemon startup in `api::serve` and reused by every request; a failed startup is logged and read routes then disclose `history_unavailable`. `ApiFailure` can 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 of `collection|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 yield `cursor_expired`. - `src/client.rs` — CLI client: endpoint resolution from `--server-url`/`LIFE_SERVER_URL`, `--token`/`LIFE_TOKEN` and 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 generated `X-Request-ID`. - `src/cli.rs` — `life agenda`, `life tasks list`, `life item get ID`: `--json` envelopes plus aligned terminal tables (agenda `TIME/KIND/TITLE/COLLECTION/CAPACITY`, tasks `ID/STATUS/STRENGTH/DEADLINE/TITLE/ESTIMATE`, item metadata/description/ETag/coverage) and a warning banner whenever `complete == false`. - `src/calendar/client.rs` — shared DAV transport now exposes response status and ETag and adds a live resource `GET`; `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`/`all` filters), C11 (transparent event visible but non-blocking; opaque tentative consumes capacity), C13 (unparseable calendar-data degrades to `unsupported`, 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 → 422 `cursor_expired`), `collection_ids` filtering and rejection of unknown collection ids, item get (live ETag + full description returned only on item get, unknown id → 404 `not_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). - Item get additionally proves it reuses the daemon's worker (`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 bare `not_found` (`test_item_get_discloses_unavailable_history_instead_of_claiming_absence`: no worker at all, and a worker whose `life_events` table was dropped, both yield `history_unavailable` with the underlying reason in the warning). - Daemon startup smoke: `life serve` creates the SQLite database at boot (single worker started before any request), `/health/live` answers, and the item route is live (malformed id → 400). - `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` are clean. ## Known deviations - Pagination re-queries the backend live for each page instead of sharing one bounded materialized read result as described in `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
Author
Owner

Coordinator review — CHANGES REQUESTED

Reviewed the full diff at f885b4c against docs/issues/009-agenda-tasks-list-cli-api.md and docs/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_capacity false 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_absence calls DatabaseWorker::start(&config.storage.database_path) on every GET /v1/items/{id}. That opens a new SQLite connection, a new OS thread named sqlite-worker, and re-runs the baseline migrations per request, and life serve never starts a worker at all (src/cli.rs serve arm only calls api::serve). docs/technical/architecture.md requires one dedicated blocking database worker owning the connection — please start a single worker at daemon startup, hold it in AppState (e.g. Arc<DatabaseWorker>), and reuse it. Every later issue (#10–#12) needs this handle in AppState anyway, so this is the right cut point.

2. (major) Absence is asserted on silently-swallowed evidence. history_absence uses DatabaseWorker::start(...).ok()?, worker.query(...).await.ok()? and row.get_str(...).unwrap_or(""), so any journal failure degrades to "no history record" and the route then returns a bare not_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, or partial), never collapsed into "does not exist".

3. (minor) Removal-kind detection guesses. is_removal_kind substring-matches "delete"/"remove" over life_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-marked ponytail:-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 review — CHANGES REQUESTED Reviewed the full diff at f885b4c against `docs/issues/009-agenda-tasks-list-cli-api.md` and `docs/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_capacity` false 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_absence` calls `DatabaseWorker::start(&config.storage.database_path)` on every `GET /v1/items/{id}`. That opens a new SQLite connection, a new OS thread named `sqlite-worker`, and re-runs the baseline migrations *per request*, and `life serve` never starts a worker at all (`src/cli.rs` serve arm only calls `api::serve`). `docs/technical/architecture.md` requires one dedicated blocking database worker owning the connection — please start a single worker at daemon startup, hold it in `AppState` (e.g. `Arc<DatabaseWorker>`), and reuse it. Every later issue (#10–#12) needs this handle in `AppState` anyway, so this is the right cut point. **2. (major) Absence is asserted on silently-swallowed evidence.** `history_absence` uses `DatabaseWorker::start(...).ok()?`, `worker.query(...).await.ok()?` and `row.get_str(...).unwrap_or("")`, so *any* journal failure degrades to "no history record" and the route then returns a bare `not_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`, or `partial`), never collapsed into "does not exist". **3. (minor) Removal-kind detection guesses.** `is_removal_kind` substring-matches `"delete"`/`"remove"` over `life_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-marked `ponytail:`-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.
Author
Owner

Coordinator re-review — APPROVED

Verified the review-action commit 1d48e50 myself (read the diff, ran the suite):

  • Shared worker: AppState.history: Option<Arc<DatabaseWorker>>, started exactly once in api::serve from config.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 disclose history_unavailable rather than silently degrading.
  • Evidence disclosure: HistoryLookup::{Found, NoRecord, Unavailable}; a failed or malformed history read produces not_found with a history_unavailable warning (ApiFailure.warnings surfaced through error_response, and the CLI now prints warnings on error envelopes). Absence is never asserted on a lookup that did not run.
  • Removal kinds: explicit REMOVAL_EVENT_KINDS contract 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 test 99 passed / 0 failed across 9 suites; cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean.

Merging.

## Coordinator re-review — APPROVED Verified the review-action commit `1d48e50` myself (read the diff, ran the suite): - **Shared worker:** `AppState.history: Option<Arc<DatabaseWorker>>`, started exactly once in `api::serve` from `config.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 disclose `history_unavailable` rather than silently degrading. - **Evidence disclosure:** `HistoryLookup::{Found, NoRecord, Unavailable}`; a failed or malformed history read produces `not_found` *with* a `history_unavailable` warning (`ApiFailure.warnings` surfaced through `error_response`, and the CLI now prints warnings on error envelopes). Absence is never asserted on a lookup that did not run. - **Removal kinds:** explicit `REMOVAL_EVENT_KINDS` contract 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 test` 99 passed / 0 failed across 9 suites; `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean. Merging.
yuris merged commit e58596a997 into main 2026-09-22 20:45:44 +00:00
yuris changed title from feat(api): agenda and tasks list cli api with completeness metrics (#9) to feat(api): agenda and tasks list cli api with completeness metrics 2026-09-23 05:12:08 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service!44
No description provided.