Agenda & Tasks List CLI/API with Completeness Metrics #9

Closed
opened 2026-09-22 17:58:54 +00:00 by yuris · 0 comments
Owner

Summary

Implement the user-facing read routes GET /v1/agenda, GET /v1/tasks, and GET /v1/items/{id} alongside their CLI counterparts life agenda, life tasks list, and life item get, delivering live schedule views, task inventories, individual item inspection with live absence verification from durable history, cursor-based pagination, capacity occupancy calculations (transparent vs opaque events), and explicit per-collection coverage metrics with honest degradation.


Background & Technical Rationale

  1. Radicale as Sole Authority for Live State:
    In accordance with the core architecture, life-service never falls back to memory, recalled context, or stale projection tables when serving live schedule and task inventory requests. Every query executes live CalDAV queries against the configured upstream Radicale collections. If a backend read fails, the service discloses partial coverage or unavailability—it never claims an empty calendar.

  2. Temporal & Capacity Semantics for Agenda Items:

    • VTODO Deadlines vs Appointments: A task's DUE property represents a deadline, and DTSTART represents earliest planned work. Neither reserves busy time. The agenda view must display deadlines distinctly from scheduled calendar events. Undated tasks must not be converted into fabricated midnight appointments.
    • Transparency & Busy Capacity:
      • Events with TRANSP:TRANSPARENT appear on the agenda (e.g. reminders, informational markers) but do not consume capacity.
      • Cancelled occurrences (STATUS:CANCELLED) consume zero capacity.
      • Opaque tentative events (TRANSP:OPAQUE, STATUS:TENTATIVE) conservatively consume busy capacity unless explicit policy overrides this.
      • If semantics cannot be resolved, the affected window cannot be declared free.
  3. Task Inventory & Undated Task Handling (Scenario W04):
    life tasks list (and GET /v1/tasks) provides the authoritative backlog of actionable obligations.

    • Default Open Filter: Returns tasks with STATUS:NEEDS-ACTION, STATUS:IN-PROCESS, or unspecified external status. Completed tasks (STATUS:COMPLETED) and cancelled tasks (STATUS:CANCELLED) are excluded by default unless explicitly requested via --status all or --status completed.
    • Undated Tasks: Tasks lacking DTSTART and DUE are valid tasks and must be included in the list.
    • Unknown Status: External items imported with unexpected or non-standard status strings must be labeled with their raw status rather than guessed or coerced into completed.
  4. Multi-Collection Coverage & Non-Atomic CalDAV Reads:
    Because CalDAV lacks multi-collection atomic snapshots:

    • Queries fetch from all mapped collections concurrently.
    • Responses include an overall complete: bool flag and a coverage array detailing per-collection observations:
      {"collection_id": "personal", "state": "complete", "as_of": "2030-04-08T00:00:00Z"}.
    • If one collection fails or times out, the overall status is partial with warning incomplete_calendar, and that collection's state is reported as unavailable.
  5. Cursor-Based Pagination Contract:

    • Default page size: 50 items; maximum allowed: 200 items.
    • Return opaque next_cursor (bound to normalized filter parameters and expiry time).
    • If a cursor has expired, return cursor_expired (HTTP 422, exit code 2), requiring the client to restart.

Exact Technical References & Specifications

Normative Quotations from Technical Architecture

From docs/technical/cli-api.md (Command-to-route table, lines 27–30):

Command Method and route Scope
life agenda --period today|tomorrow|week GET /v1/agenda read
life tasks list GET /v1/tasks read
life item get ID GET /v1/items/{id} read

From docs/technical/cli-api.md (Common data types and pagination, lines 71–74):

"Item fields: id, kind (task/event), collection_id, uid, nullable recurrence_id (tagged date/instant plus original timezone identity), etag, title, status, categories, nullable strength, deadline, planned_start, start, end, estimated_minutes, energy, project_id, related_task_id. Irrelevant fields are omitted, unknown applicable values are null. description is optional and returned only on item get or explicit inclusion. Task-only optional properties estimated_minutes, energy and project_id map to preservation-safe X-LIFE-ESTIMATED-MINUTES, X-LIFE-ENERGY, X-LIFE-PROJECT-ID; energy enum is low/medium/high. These are user assertions, not model-filled facts.

Lists return items, next_cursor (null at end), as_of, complete, coverage and limits. Default limit 50; max 200. Cursor is opaque, bound to normalized filters and expiry. Pages share a bounded materialized read result; expiry yields cursor_expired and requires restarting. complete describes backend/interpretation coverage, not whether more pages remain. coverage lists collection IDs, observed times and complete|unavailable|unsupported|concurrent_change; live CalDAV reads are not globally atomic. Never silently truncate: expansion/candidate limits and remaining pages are explicit."

From docs/technical/cli-api.md (Command payloads and results, lines 78–81):

Family Required / optional input Response data
Agenda period or from/to; optional collection IDs; configured timezone List of occurrence Items plus from, to, timezone, deadline entries; no claim of free time unless complete
Tasks list Optional strength, area, status, due_before; default open, including undated Paginated Items
Item get ID item, as_of, complete, coverage

From docs/technical/calendar.md (Query and time semantics, lines 24–28):

"- VTODO DUE is a deadline, DTSTART planned earliest work; neither alone reserves busy time. Undated tasks remain in tasks list and planning candidates. Date-only deadline urgency uses the end of that local date while preserving its date representation. Do not invent midnight appointments.

  • Transparent VEVENTs appear in agenda but do not consume busy capacity. Cancelled occurrences do not consume capacity. Opaque tentative events conservatively count busy unless policy explicitly says otherwise. Missing or unsupported semantics prevent declaring the affected window free.

Schedule answers always perform current backend reads, return as_of/per-collection observation times and completeness. CalDAV has no global snapshot: concurrent changes can produce an explicitly incomplete observation despite successful individual requests. Memory is never fallback authority; a failed calendar read is not an empty agenda."

From docs/technical/verification.md (Workflow & Calendar scenarios, lines 17–18, 45):

ID Requirement and specification Input / action Observable acceptance
W03 Agenda Today, tomorrow and week in configured zone Correct local bounds, live reads, as_of and per-collection completeness; deadlines distinct from events
W04 Open tasks Open undated task plus completed task and unknown-status external task Undated open included; completed excluded by default; unknown status labeled rather than guessed
C11 Transparent event and tentative opaque event Transparent event and tentative opaque event Both visible; transparent does not consume capacity, opaque does

Response JSON Envelopes

1. GET /v1/agenda Successful Response (200 OK)

{
  "schema_version": 1,
  "request_id": "req_demo_agenda",
  "status": "ok",
  "data": {
    "items": [
      {
        "id": "itm_event_01",
        "kind": "event",
        "collection_id": "personal",
        "uid": "uid-event-sync",
        "recurrence_id": null,
        "etag": "\"v1\"",
        "title": "Architecture sync",
        "status": "CONFIRMED",
        "categories": ["work"],
        "strength": null,
        "start": { "kind": "instant", "value": "2030-04-08T09:00:00Z" },
        "end": { "kind": "instant", "value": "2030-04-08T10:00:00Z" },
        "transparency": "opaque",
        "consumes_capacity": true
      },
      {
        "id": "itm_event_02",
        "kind": "event",
        "collection_id": "personal",
        "uid": "uid-event-reminder",
        "recurrence_id": null,
        "etag": "\"v2\"",
        "title": "Submit expense receipts",
        "status": "CONFIRMED",
        "categories": ["admin"],
        "strength": null,
        "start": { "kind": "instant", "value": "2030-04-08T11:00:00Z" },
        "end": { "kind": "instant", "value": "2030-04-08T11:30:00Z" },
        "transparency": "transparent",
        "consumes_capacity": false
      },
      {
        "id": "itm_task_01",
        "kind": "task",
        "collection_id": "personal",
        "uid": "uid-task-spec",
        "recurrence_id": null,
        "etag": "\"v3\"",
        "title": "Complete RFC review",
        "status": "NEEDS-ACTION",
        "categories": ["area-career", "life-active"],
        "strength": "life-active",
        "deadline": { "kind": "date", "value": "2030-04-08" },
        "planned_start": null,
        "consumes_capacity": false
      }
    ],
    "from": "2030-04-07T14:00:00Z",
    "to": "2030-04-08T14:00:00Z",
    "timezone": "Australia/Brisbane",
    "as_of": "2030-04-08T00:00:00Z",
    "complete": true,
    "coverage": [
      {
        "collection_id": "personal",
        "state": "complete",
        "as_of": "2030-04-08T00:00:00Z"
      }
    ],
    "limits": {
      "limit": 50,
      "max_limit": 200
    },
    "next_cursor": null
  },
  "warnings": [],
  "error": null
}

2. GET /v1/tasks Response with Undated Tasks (200 OK)

{
  "schema_version": 1,
  "request_id": "req_tasks_list",
  "status": "ok",
  "data": {
    "items": [
      {
        "id": "itm_task_undated",
        "kind": "task",
        "collection_id": "personal",
        "uid": "uid-undated-idea",
        "recurrence_id": null,
        "etag": "\"v1\"",
        "title": "Explore vector index benchmarking",
        "status": "NEEDS-ACTION",
        "categories": ["area-career", "life-queued"],
        "strength": "life-queued",
        "deadline": null,
        "planned_start": null,
        "estimated_minutes": 120,
        "energy": "high",
        "project_id": "proj-bench"
      }
    ],
    "as_of": "2030-04-08T00:00:00Z",
    "complete": true,
    "coverage": [
      {
        "collection_id": "personal",
        "state": "complete",
        "as_of": "2030-04-08T00:00:00Z"
      }
    ],
    "limits": {
      "limit": 50,
      "max_limit": 200
    },
    "next_cursor": null
  },
  "warnings": [],
  "error": null
}

3. Incomplete Coverage Warning Response (200 OK, status: partial)

{
  "schema_version": 1,
  "request_id": "req_agenda_partial",
  "status": "partial",
  "data": {
    "items": [],
    "from": "2030-04-07T14:00:00Z",
    "to": "2030-04-08T14:00:00Z",
    "timezone": "Australia/Brisbane",
    "as_of": "2030-04-08T00:00:00Z",
    "complete": false,
    "coverage": [
      {
        "collection_id": "personal",
        "state": "complete",
        "as_of": "2030-04-08T00:00:00Z"
      },
      {
        "collection_id": "work",
        "state": "unavailable",
        "as_of": "2030-04-08T00:00:00Z"
      }
    ],
    "limits": {
      "limit": 50,
      "max_limit": 200
    },
    "next_cursor": null
  },
  "warnings": [
    {
      "code": "incomplete_calendar",
      "message": "Collection 'work' was unavailable; schedule view is partial and cannot claim free time"
    }
  ],
  "error": null
}

What to Build (Step-by-Step)

1. Unified Domain Model (src/domain/item.rs)

  • Define Item struct mapping all standard fields from docs/technical/cli-api.md:
    • id: Opaque string identifier (itm_<base32>).
    • kind: ItemKind::Task or ItemKind::Event.
    • collection_id: Logical collection ID.
    • uid: Authoritative CalDAV UID string.
    • recurrence_id: Nullable TaggedTime.
    • etag: Quoted entity tag ("\"...\"").
    • title: Summary string.
    • status: String representation (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED, CONFIRMED, TENTATIVE).
    • categories: Array of strings (containing strength like life-active and areas like area-career).
    • strength: Nullable CommitmentStrength (life-active, life-queued, life-someday).
    • deadline, planned_start, start, end: Nullable TaggedTime.
    • estimated_minutes: Optional integer (from X-LIFE-ESTIMATED-MINUTES).
    • energy: Optional enum (low, medium, high from X-LIFE-ENERGY).
    • project_id: Optional string (from X-LIFE-PROJECT-ID).
    • consumes_capacity: Boolean computed based on transparency and status.

2. Coverage & Aggregation Engine (src/calendar/aggregation.rs)

  • Query execution over multiple collections:
    • Fan out REPORT calendar-query across all configured logical collections in parallel using futures::future::join_all.
    • For each collection response:
      • If the request succeeds and all items parse with supported semantics:
        • Record CollectionCoverage { collection_id, state: CoverageState::Complete, as_of: Utc::now() }.
      • If the collection contains unsupported properties or recurrences that cannot be interpreted:
        • Record CollectionCoverage { collection_id, state: CoverageState::Unsupported, as_of: Utc::now() }.
      • If the REPORT succeeds but subsequent GETs experience ETag drift across bounded retries:
        • Record CollectionCoverage { collection_id, state: CoverageState::ConcurrentChange, as_of: Utc::now() }.
      • If a collection request fails or times out:
        • Record CollectionCoverage { collection_id, state: CoverageState::Unavailable, as_of: Utc::now() }.
    • Compute overall complete = coverage.iter().all(|c| c.state == CoverageState::Complete).
    • If all fail, return dependency_unavailable. If some succeed and some fail, return status: partial with warning incomplete_calendar or concurrent_change.

3. Cursor Pagination Engine (src/api/pagination.rs)

  • Implement cursor encoding/decoding:
    • Cursor payload: (query_hash, offset, expiry_timestamp).
    • Encrypt or sign with HMAC using a session secret.
    • Enforce expiry (e.g. 15 minutes). If expired, return cursor_expired (HTTP 422 / exit code 2).
    • Clamp client limit: default 50, maximum 200.

4. Axum Handlers (src/api/agenda.rs, src/api/tasks.rs, src/api/items.rs)

  • GET /v1/agenda:
    • Query parameters: period (today, tomorrow, week) OR from and to ISO strings. Optional collection_ids.
    • Fetch expanded VEVENT occurrences and active VTODO items falling in the time window.
    • Sort items: events by start, tasks by deadline urgency.
  • GET /v1/tasks:
    • Query parameters: status (open | completed | cancelled | all), strength (life-active | life-queued | life-someday), limit, cursor.
    • Filter: if status is open, match NEEDS-ACTION, IN-PROCESS, or unknown non-completed statuses.
    • Explicitly include undated tasks (where deadline and planned_start are null).
  • GET /v1/items/{id}:
    • Resolves item by opaque string ID (itm_...) using local projection/mapping to find (collection_id, uid, recurrence_id).
    • Performs a live read against Radicale for the authoritative resource to return current ETag, live presence, and full description (which is returned only on item get).
    • If absent on live Radicale:
      • Queries SQLite durable history (life_events / operations) to determine if the item was previously deleted.
      • If found in history as deleted, confirms live absence rather than returning stale projection state, returning not_found (HTTP 404, CLI exit 1) with historical context.
      • If not found in history or projection, returns not_found (HTTP 404, CLI exit 1).
    • Response data: { item: Item, as_of: DateTime<Utc>, complete: bool, coverage: Vec<CollectionCoverage> }.

5. CLI Rendering (src/cli/agenda.rs, src/cli/tasks.rs, src/cli/items.rs)

  • Implement life agenda, life tasks list, and life item get <ID>:
    • --json: Output strict JSON envelope matching specification.
    • Default terminal output: Formatted ASCII/ANSI tables:
      • Agenda: Display columns TIME, KIND, TITLE, COLLECTION, CAPACITY (indicating whether the item blocks capacity).
      • Tasks: Display columns ID, STATUS, STRENGTH, DEADLINE, TITLE, ESTIMATE.
      • Item get: Display item metadata, full description, current ETag, and coverage status.
      • If complete == false, print a noticeable warning banner indicating incomplete calendar coverage.

Acceptance Criteria

  • Live Authority: Both life agenda and life tasks list perform live HTTP CalDAV queries against Radicale; they never serve cached text from memory or SQLite without a live check.
  • Agenda Local Boundaries (Scenario W03): Querying today, tomorrow, and week returns items partitioned by local midnight in the configured IANA timezone, displaying deadlines distinctly from calendar events.
  • Event Capacity Rules (Scenario C11):
    • An event with TRANSP:TRANSPARENT has consumes_capacity: false and is indicated as non-blocking.
    • An opaque tentative event (TRANSP:OPAQUE, STATUS:TENTATIVE) has consumes_capacity: true.
    • Cancelled events have consumes_capacity: false.
  • Undated Tasks Retention (Scenario W04): life tasks list includes undated tasks by default; they are never converted to fake midnight appointments or dropped from results.
  • Open Tasks Status Filtering (Scenario W04): Default task list excludes COMPLETED and CANCELLED tasks, while including NEEDS-ACTION, IN-PROCESS, and external tasks with unknown non-standard status strings (retaining the raw status label).
  • Date-Only & Undated Deadlines (Scenario C02): A task without DTSTART or DUE remains visible in task listings and candidate sets; a date-only deadline (VALUE=DATE) is displayed as that local date and not converted to a UTC midnight appointment.
  • Unsupported Property Preservation (Scenario C13): Unsupported calendar properties and recurrence structures are either preserved without loss or explicitly reported via coverage: [{state: "unsupported"}] and complete: false, never silently dropped while claiming complete coverage.
  • Concurrent Modification Disclosure (Scenario C14): When a REPORT succeeds but subsequent individual GETs observe changing ETags across bounded retries, the service returns warnings: ["concurrent_change"] with complete: false, rather than claiming a globally atomic snapshot.
  • Live Item Inspection (life item get): Rereads the item live from Radicale to verify current ETag, returning full description; resolving an item deleted from Radicale checks durable history to confirm live absence rather than returning stale cached projection.
  • Per-Collection Coverage & Partial Failures: If one of multiple collections is unavailable, response status is partial, overall complete is false, and coverage details which collection failed; free time is explicitly not declared.
  • Pagination Invariants: Paged task queries obey default limit 50, clamp at 200, return opaque next_cursor, and return cursor_expired (exit code 2) when an expired cursor is passed.
  • JSON Schema Conformance: Output with --json matches the root envelope (schema_version: 1, request_id, status, data, warnings, error).

Notes

  • Follow-up filed as #45: the shared materialized pagination read result described in docs/technical/cli-api.md is deliberately deferred from this slice (documented deviation in PR #44).

Blocked by

  • #008: Bounded CalDAV REPORT Calendar-Query & Timezone Expansion
## Summary Implement the user-facing read routes `GET /v1/agenda`, `GET /v1/tasks`, and `GET /v1/items/{id}` alongside their CLI counterparts `life agenda`, `life tasks list`, and `life item get`, delivering live schedule views, task inventories, individual item inspection with live absence verification from durable history, cursor-based pagination, capacity occupancy calculations (transparent vs opaque events), and explicit per-collection coverage metrics with honest degradation. --- ## Background & Technical Rationale 1. **Radicale as Sole Authority for Live State:** In accordance with the core architecture, `life-service` never falls back to memory, recalled context, or stale projection tables when serving live schedule and task inventory requests. Every query executes live CalDAV queries against the configured upstream Radicale collections. If a backend read fails, the service discloses partial coverage or unavailability—it **never** claims an empty calendar. 2. **Temporal & Capacity Semantics for Agenda Items:** - **VTODO Deadlines vs Appointments:** A task's `DUE` property represents a deadline, and `DTSTART` represents earliest planned work. Neither reserves busy time. The agenda view must display deadlines distinctly from scheduled calendar events. Undated tasks must not be converted into fabricated midnight appointments. - **Transparency & Busy Capacity:** - Events with `TRANSP:TRANSPARENT` appear on the agenda (e.g. reminders, informational markers) but do not consume capacity. - Cancelled occurrences (`STATUS:CANCELLED`) consume zero capacity. - Opaque tentative events (`TRANSP:OPAQUE`, `STATUS:TENTATIVE`) conservatively consume busy capacity unless explicit policy overrides this. - If semantics cannot be resolved, the affected window cannot be declared free. 3. **Task Inventory & Undated Task Handling (Scenario W04):** `life tasks list` (and `GET /v1/tasks`) provides the authoritative backlog of actionable obligations. - **Default Open Filter:** Returns tasks with `STATUS:NEEDS-ACTION`, `STATUS:IN-PROCESS`, or unspecified external status. Completed tasks (`STATUS:COMPLETED`) and cancelled tasks (`STATUS:CANCELLED`) are excluded by default unless explicitly requested via `--status all` or `--status completed`. - **Undated Tasks:** Tasks lacking `DTSTART` and `DUE` are valid tasks and must be included in the list. - **Unknown Status:** External items imported with unexpected or non-standard status strings must be labeled with their raw status rather than guessed or coerced into completed. 4. **Multi-Collection Coverage & Non-Atomic CalDAV Reads:** Because CalDAV lacks multi-collection atomic snapshots: - Queries fetch from all mapped collections concurrently. - Responses include an overall `complete: bool` flag and a `coverage` array detailing per-collection observations: `{"collection_id": "personal", "state": "complete", "as_of": "2030-04-08T00:00:00Z"}`. - If one collection fails or times out, the overall status is `partial` with warning `incomplete_calendar`, and that collection's state is reported as `unavailable`. 5. **Cursor-Based Pagination Contract:** - Default page size: 50 items; maximum allowed: 200 items. - Return opaque `next_cursor` (bound to normalized filter parameters and expiry time). - If a cursor has expired, return `cursor_expired` (HTTP 422, exit code 2), requiring the client to restart. --- ## Exact Technical References & Specifications ### Normative Quotations from Technical Architecture From `docs/technical/cli-api.md` (*Command-to-route table*, lines 27–30): > | Command | Method and route | Scope | > |---|---|---| > | `life agenda --period today\|tomorrow\|week` | GET /v1/agenda | read | > | `life tasks list` | GET /v1/tasks | read | > | `life item get ID` | GET /v1/items/{id} | read | From `docs/technical/cli-api.md` (*Common data types and pagination*, lines 71–74): > "`Item` fields: `id`, `kind` (task/event), `collection_id`, `uid`, nullable `recurrence_id` (tagged date/instant plus original timezone identity), `etag`, `title`, `status`, `categories`, nullable `strength`, `deadline`, `planned_start`, `start`, `end`, `estimated_minutes`, `energy`, `project_id`, `related_task_id`. Irrelevant fields are omitted, unknown applicable values are null. `description` is optional and returned only on item get or explicit inclusion. Task-only optional properties estimated_minutes, energy and project_id map to preservation-safe `X-LIFE-ESTIMATED-MINUTES`, `X-LIFE-ENERGY`, `X-LIFE-PROJECT-ID`; energy enum is low/medium/high. These are user assertions, not model-filled facts. > > Lists return `items`, `next_cursor` (null at end), `as_of`, `complete`, `coverage` and `limits`. Default limit 50; max 200. Cursor is opaque, bound to normalized filters and expiry. Pages share a bounded materialized read result; expiry yields `cursor_expired` and requires restarting. `complete` describes backend/interpretation coverage, not whether more pages remain. `coverage` lists collection IDs, observed times and `complete|unavailable|unsupported|concurrent_change`; live CalDAV reads are not globally atomic. Never silently truncate: expansion/candidate limits and remaining pages are explicit." From `docs/technical/cli-api.md` (*Command payloads and results*, lines 78–81): > | Family | Required / optional input | Response data | > |---|---|---| > | Agenda | period or from/to; optional collection IDs; configured timezone | List of occurrence Items plus `from`, `to`, `timezone`, deadline entries; no claim of free time unless complete | > | Tasks list | Optional strength, area, status, due_before; default open, including undated | Paginated Items | > | Item get | ID | `item`, `as_of`, `complete`, coverage | From `docs/technical/calendar.md` (*Query and time semantics*, lines 24–28): > "- VTODO DUE is a deadline, DTSTART planned earliest work; neither alone reserves busy time. Undated tasks remain in tasks list and planning candidates. Date-only deadline urgency uses the end of that local date while preserving its date representation. Do not invent midnight appointments. > - Transparent VEVENTs appear in agenda but do not consume busy capacity. Cancelled occurrences do not consume capacity. Opaque tentative events conservatively count busy unless policy explicitly says otherwise. Missing or unsupported semantics prevent declaring the affected window free. > > Schedule answers always perform current backend reads, return as_of/per-collection observation times and completeness. CalDAV has no global snapshot: concurrent changes can produce an explicitly incomplete observation despite successful individual requests. Memory is never fallback authority; a failed calendar read is not an empty agenda." From `docs/technical/verification.md` (*Workflow & Calendar scenarios*, lines 17–18, 45): > | ID | Requirement and specification | Input / action | Observable acceptance | > |---|---|---|---| > | `W03` | [Agenda](calendar.md#query-and-time-semantics) | Today, tomorrow and week in configured zone | Correct local bounds, live reads, as_of and per-collection completeness; deadlines distinct from events | > | `W04` | [Open tasks](cli-api.md#command-payloads-and-results) | Open undated task plus completed task and unknown-status external task | Undated open included; completed excluded by default; unknown status labeled rather than guessed | > | `C11` | Transparent event and tentative opaque event | Transparent event and tentative opaque event | Both visible; transparent does not consume capacity, opaque does | --- ### Response JSON Envelopes #### 1. `GET /v1/agenda` Successful Response (`200 OK`) ```json { "schema_version": 1, "request_id": "req_demo_agenda", "status": "ok", "data": { "items": [ { "id": "itm_event_01", "kind": "event", "collection_id": "personal", "uid": "uid-event-sync", "recurrence_id": null, "etag": "\"v1\"", "title": "Architecture sync", "status": "CONFIRMED", "categories": ["work"], "strength": null, "start": { "kind": "instant", "value": "2030-04-08T09:00:00Z" }, "end": { "kind": "instant", "value": "2030-04-08T10:00:00Z" }, "transparency": "opaque", "consumes_capacity": true }, { "id": "itm_event_02", "kind": "event", "collection_id": "personal", "uid": "uid-event-reminder", "recurrence_id": null, "etag": "\"v2\"", "title": "Submit expense receipts", "status": "CONFIRMED", "categories": ["admin"], "strength": null, "start": { "kind": "instant", "value": "2030-04-08T11:00:00Z" }, "end": { "kind": "instant", "value": "2030-04-08T11:30:00Z" }, "transparency": "transparent", "consumes_capacity": false }, { "id": "itm_task_01", "kind": "task", "collection_id": "personal", "uid": "uid-task-spec", "recurrence_id": null, "etag": "\"v3\"", "title": "Complete RFC review", "status": "NEEDS-ACTION", "categories": ["area-career", "life-active"], "strength": "life-active", "deadline": { "kind": "date", "value": "2030-04-08" }, "planned_start": null, "consumes_capacity": false } ], "from": "2030-04-07T14:00:00Z", "to": "2030-04-08T14:00:00Z", "timezone": "Australia/Brisbane", "as_of": "2030-04-08T00:00:00Z", "complete": true, "coverage": [ { "collection_id": "personal", "state": "complete", "as_of": "2030-04-08T00:00:00Z" } ], "limits": { "limit": 50, "max_limit": 200 }, "next_cursor": null }, "warnings": [], "error": null } ``` #### 2. `GET /v1/tasks` Response with Undated Tasks (`200 OK`) ```json { "schema_version": 1, "request_id": "req_tasks_list", "status": "ok", "data": { "items": [ { "id": "itm_task_undated", "kind": "task", "collection_id": "personal", "uid": "uid-undated-idea", "recurrence_id": null, "etag": "\"v1\"", "title": "Explore vector index benchmarking", "status": "NEEDS-ACTION", "categories": ["area-career", "life-queued"], "strength": "life-queued", "deadline": null, "planned_start": null, "estimated_minutes": 120, "energy": "high", "project_id": "proj-bench" } ], "as_of": "2030-04-08T00:00:00Z", "complete": true, "coverage": [ { "collection_id": "personal", "state": "complete", "as_of": "2030-04-08T00:00:00Z" } ], "limits": { "limit": 50, "max_limit": 200 }, "next_cursor": null }, "warnings": [], "error": null } ``` #### 3. Incomplete Coverage Warning Response (`200 OK`, `status: partial`) ```json { "schema_version": 1, "request_id": "req_agenda_partial", "status": "partial", "data": { "items": [], "from": "2030-04-07T14:00:00Z", "to": "2030-04-08T14:00:00Z", "timezone": "Australia/Brisbane", "as_of": "2030-04-08T00:00:00Z", "complete": false, "coverage": [ { "collection_id": "personal", "state": "complete", "as_of": "2030-04-08T00:00:00Z" }, { "collection_id": "work", "state": "unavailable", "as_of": "2030-04-08T00:00:00Z" } ], "limits": { "limit": 50, "max_limit": 200 }, "next_cursor": null }, "warnings": [ { "code": "incomplete_calendar", "message": "Collection 'work' was unavailable; schedule view is partial and cannot claim free time" } ], "error": null } ``` --- ## What to Build (Step-by-Step) ### 1. Unified Domain Model (`src/domain/item.rs`) - Define `Item` struct mapping all standard fields from `docs/technical/cli-api.md`: - `id`: Opaque string identifier (`itm_<base32>`). - `kind`: `ItemKind::Task` or `ItemKind::Event`. - `collection_id`: Logical collection ID. - `uid`: Authoritative CalDAV UID string. - `recurrence_id`: Nullable `TaggedTime`. - `etag`: Quoted entity tag (`"\"...\""`). - `title`: Summary string. - `status`: String representation (`NEEDS-ACTION`, `IN-PROCESS`, `COMPLETED`, `CANCELLED`, `CONFIRMED`, `TENTATIVE`). - `categories`: Array of strings (containing strength like `life-active` and areas like `area-career`). - `strength`: Nullable `CommitmentStrength` (`life-active`, `life-queued`, `life-someday`). - `deadline`, `planned_start`, `start`, `end`: Nullable `TaggedTime`. - `estimated_minutes`: Optional integer (from `X-LIFE-ESTIMATED-MINUTES`). - `energy`: Optional enum (`low`, `medium`, `high` from `X-LIFE-ENERGY`). - `project_id`: Optional string (from `X-LIFE-PROJECT-ID`). - `consumes_capacity`: Boolean computed based on transparency and status. ### 2. Coverage & Aggregation Engine (`src/calendar/aggregation.rs`) - Query execution over multiple collections: - Fan out `REPORT calendar-query` across all configured logical collections in parallel using `futures::future::join_all`. - For each collection response: - If the request succeeds and all items parse with supported semantics: - Record `CollectionCoverage { collection_id, state: CoverageState::Complete, as_of: Utc::now() }`. - If the collection contains unsupported properties or recurrences that cannot be interpreted: - Record `CollectionCoverage { collection_id, state: CoverageState::Unsupported, as_of: Utc::now() }`. - If the REPORT succeeds but subsequent GETs experience ETag drift across bounded retries: - Record `CollectionCoverage { collection_id, state: CoverageState::ConcurrentChange, as_of: Utc::now() }`. - If a collection request fails or times out: - Record `CollectionCoverage { collection_id, state: CoverageState::Unavailable, as_of: Utc::now() }`. - Compute overall `complete = coverage.iter().all(|c| c.state == CoverageState::Complete)`. - If all fail, return `dependency_unavailable`. If some succeed and some fail, return `status: partial` with warning `incomplete_calendar` or `concurrent_change`. ### 3. Cursor Pagination Engine (`src/api/pagination.rs`) - Implement cursor encoding/decoding: - Cursor payload: `(query_hash, offset, expiry_timestamp)`. - Encrypt or sign with HMAC using a session secret. - Enforce expiry (e.g. 15 minutes). If expired, return `cursor_expired` (HTTP 422 / exit code 2). - Clamp client limit: default 50, maximum 200. ### 4. Axum Handlers (`src/api/agenda.rs`, `src/api/tasks.rs`, `src/api/items.rs`) - `GET /v1/agenda`: - Query parameters: `period` (`today`, `tomorrow`, `week`) OR `from` and `to` ISO strings. Optional `collection_ids`. - Fetch expanded `VEVENT` occurrences and active `VTODO` items falling in the time window. - Sort items: events by `start`, tasks by `deadline` urgency. - `GET /v1/tasks`: - Query parameters: `status` (`open` | `completed` | `cancelled` | `all`), `strength` (`life-active` | `life-queued` | `life-someday`), `limit`, `cursor`. - Filter: if `status` is `open`, match `NEEDS-ACTION`, `IN-PROCESS`, or unknown non-completed statuses. - Explicitly include undated tasks (where `deadline` and `planned_start` are null). - `GET /v1/items/{id}`: - Resolves item by opaque string ID (`itm_...`) using local projection/mapping to find `(collection_id, uid, recurrence_id)`. - Performs a live read against Radicale for the authoritative resource to return current ETag, live presence, and full `description` (which is returned only on item get). - If absent on live Radicale: - Queries SQLite durable history (`life_events` / `operations`) to determine if the item was previously deleted. - If found in history as deleted, confirms live absence rather than returning stale projection state, returning `not_found` (HTTP 404, CLI exit 1) with historical context. - If not found in history or projection, returns `not_found` (HTTP 404, CLI exit 1). - Response data: `{ item: Item, as_of: DateTime<Utc>, complete: bool, coverage: Vec<CollectionCoverage> }`. ### 5. CLI Rendering (`src/cli/agenda.rs`, `src/cli/tasks.rs`, `src/cli/items.rs`) - Implement `life agenda`, `life tasks list`, and `life item get <ID>`: - `--json`: Output strict JSON envelope matching specification. - Default terminal output: Formatted ASCII/ANSI tables: - Agenda: Display columns `TIME`, `KIND`, `TITLE`, `COLLECTION`, `CAPACITY` (indicating whether the item blocks capacity). - Tasks: Display columns `ID`, `STATUS`, `STRENGTH`, `DEADLINE`, `TITLE`, `ESTIMATE`. - Item get: Display item metadata, full description, current ETag, and coverage status. - If `complete == false`, print a noticeable warning banner indicating incomplete calendar coverage. --- ## Acceptance Criteria - [x] **Live Authority:** Both `life agenda` and `life tasks list` perform live HTTP CalDAV queries against Radicale; they never serve cached text from memory or SQLite without a live check. - [x] **Agenda Local Boundaries (Scenario W03):** Querying `today`, `tomorrow`, and `week` returns items partitioned by local midnight in the configured IANA timezone, displaying deadlines distinctly from calendar events. - [x] **Event Capacity Rules (Scenario C11):** - An event with `TRANSP:TRANSPARENT` has `consumes_capacity: false` and is indicated as non-blocking. - An opaque tentative event (`TRANSP:OPAQUE`, `STATUS:TENTATIVE`) has `consumes_capacity: true`. - Cancelled events have `consumes_capacity: false`. - [x] **Undated Tasks Retention (Scenario W04):** `life tasks list` includes undated tasks by default; they are never converted to fake midnight appointments or dropped from results. - [x] **Open Tasks Status Filtering (Scenario W04):** Default task list excludes `COMPLETED` and `CANCELLED` tasks, while including `NEEDS-ACTION`, `IN-PROCESS`, and external tasks with unknown non-standard status strings (retaining the raw status label). - [x] **Date-Only & Undated Deadlines (Scenario C02):** A task without `DTSTART` or `DUE` remains visible in task listings and candidate sets; a date-only deadline (`VALUE=DATE`) is displayed as that local date and not converted to a UTC midnight appointment. - [x] **Unsupported Property Preservation (Scenario C13):** Unsupported calendar properties and recurrence structures are either preserved without loss or explicitly reported via `coverage: [{state: "unsupported"}]` and `complete: false`, never silently dropped while claiming complete coverage. - [x] **Concurrent Modification Disclosure (Scenario C14):** When a REPORT succeeds but subsequent individual GETs observe changing ETags across bounded retries, the service returns `warnings: ["concurrent_change"]` with `complete: false`, rather than claiming a globally atomic snapshot. - [x] **Live Item Inspection (`life item get`):** Rereads the item live from Radicale to verify current ETag, returning full `description`; resolving an item deleted from Radicale checks durable history to confirm live absence rather than returning stale cached projection. - [x] **Per-Collection Coverage & Partial Failures:** If one of multiple collections is unavailable, response status is `partial`, overall `complete` is false, and coverage details which collection failed; free time is explicitly not declared. - [x] **Pagination Invariants:** Paged task queries obey default limit 50, clamp at 200, return opaque `next_cursor`, and return `cursor_expired` (exit code 2) when an expired cursor is passed. - [x] **JSON Schema Conformance:** Output with `--json` matches the root envelope (`schema_version: 1`, `request_id`, `status`, `data`, `warnings`, `error`). --- ## Notes - Follow-up filed as #45: the shared materialized pagination read result described in `docs/technical/cli-api.md` is deliberately deferred from this slice (documented deviation in PR #44). ## Blocked by - #008: Bounded CalDAV REPORT Calendar-Query & Timezone Expansion
yuris changed title from Issue #009: Agenda & Tasks List CLI/API with Completeness Metrics to Agenda & Tasks List CLI/API with Completeness Metrics 2026-09-22 20:02:39 +00:00
yuris closed this issue 2026-09-22 20:45:44 +00:00
Sign in to join this conversation.
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#9
No description provided.