Compare commits

..

6 Commits

Author SHA1 Message Date
bvandeusen f85b92a885 Release v26.04.29.3 — Event-store data integrity + EventSlideOver redesign 2026-04-29 20:04:43 +00:00
bvandeusen 84640a0dc4 fix(events-tools): refuse ambiguous query, require event_id to disambiguate (#161)
Root cause of the 2026-04-29 dentist-appointment incident: the model
called update_event(query="Appointment") when two events had
"Appointment" in their titles. find_events_by_query returned both,
upcoming-first ordered by start_dt — matches[0] was id=2 (a stale
pre-existing event with garbage end_dt), not id=15 (the one the user
just created via the journal flow). update_event_tool silently took
matches[0] and mutated the wrong event.

Fix: a new resolver helper `_resolve_event_for_action` funnels both
update_event_tool and delete_event_tool through one disambiguation
path. Lookup precedence:
  - `event_id` → exact get_event lookup, no query at all
  - `query` matching exactly one event → proceed
  - `query` matching zero → return success=False, "no event found"
  - `query` matching 2+ events → return success=False with a
    `candidates` array of {id, title, start_dt, location} so the
    model can pick one and call again with `event_id`

The candidates list is capped at 8 to keep the model's context tight.
The error message names the count and the next-step ("pass event_id
or refine the query") so the model can self-correct in one turn.

For delete_event, the disambiguation is even more important — the
silent-matches[0] path would have deleted the wrong event outright
rather than just mutating it. The tool description leans into that:
"Deleting the wrong event is a costly user error; never guess."

Tool surface change: `query` and `event_id` are now both optional;
the tool errors clearly when neither is supplied. The model already
knows id values from prior tool results (returned in `data.id`),
which is the natural feeder for the disambiguation flow.

5 new tests in test_calendar_tool_tz.py cover:
- ambiguous query → success=False with candidate list, no mutation
- event_id supplied → bypasses query lookup entirely
- non-existent event_id → clear "no event found" error
- neither identifier → "query or event_id required" error
- same disambiguation enforced for delete_event_tool

46 calendar/events tests pass; ruff clean.

Closes Fable #161.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:29:13 -04:00
bvandeusen 4c58603009 feat(events): replace end_dt column with duration_minutes (#160)
Structural fix for the "end before start" bug class observed on prod
2026-04-29. Bad data became inexpressible at the schema level instead
of getting trapped in defensive read-path filters.

The hotfix that landed earlier today (94b169f) is reverted by the
preceding revert commit; this commit supersedes it cleanly with a
proper data-model change.

## Schema (migration 0043)

- Add `duration_minutes INTEGER NULLABLE` column on `events`.
- CHECK constraint: ``duration_minutes IS NULL OR duration_minutes >= 0``.
- Backfill from existing `end_dt`:
    - end_dt valid (end > start) → duration_minutes = total minutes
    - end_dt == start → duration_minutes = 0 (zero-duration point)
    - end_dt NULL or end_dt < start → duration_minutes = NULL
      (the corrupt prod row collapses cleanly to a point event)
- Drop the `end_dt` column. The wire format is preserved — `to_dict()`
  emits `end_dt` as a derived `start_dt + duration_minutes`. Existing
  API consumers (Flutter app, web frontend, CalDAV sync) keep
  receiving the same response shape; they just no longer have a way
  to PUT a stored `end_dt` that disagrees with `start_dt`.

## Service layer

- `Event.end_dt` becomes a `@property`. Setting it would require a
  setter we deliberately don't define — writes always go through
  `duration_minutes`.
- `_normalize_duration` is the single source-of-truth for input
  reduction. Accepts (start, end_dt, duration_minutes), returns the
  canonical `duration_minutes`, raises `ValueError` for negative
  durations, end-before-start, or end/duration disagreement.
- `create_event` and `update_event` accept either `end_dt` or
  `duration_minutes` for ergonomic compat; both convert via
  `_normalize_duration`. Update validates the post-update state when
  the patch includes either.
- `list_events` filter is simpler now: a coarse SQL prefilter
  (`start_dt <= date_to`) plus Python-side refinement using the
  derived `end_dt`. Avoids Postgres-specific interval arithmetic in
  the WHERE clause; refinement runs over a per-user result set so
  there's no scan-cost concern at personal scale.
- Recurring-event expansion uses `event.duration_minutes` directly
  instead of computing `end - start`. No more negative-timedelta
  hazard.

## CalDAV sync (incoming + outgoing)

- `caldav_sync.py` (pull) and `calendar_sync.py` (Radicale upsert)
  both convert iCal `DTEND` → `duration_minutes` on the way in.
  Outbound iCal still emits `DTEND` as `start_dt + duration_minutes`
  via the model's derived property. iCal interop is unchanged.

## Behavioral upgrade for `update_event`

Pure end_dt model: moving start past the existing end_dt would either
silently corrupt or hard-reject. Duration model: the duration is
preserved by default, so moving start slides the effective end
forward — which is what users mean when they "move" an event.
Explicit clear is still possible via `end_dt=None`.

## Tests

`tests/test_events_service.py`:
- 6 new `_normalize_duration` unit tests (sugar conversion, zero
  duration valid as point event, end-before-start rejected, negative
  duration rejected, inconsistent end+duration rejected, none → None)
- New behavioral test: `update_event` preserves duration when only
  start_dt changes (sliding semantics)
- New: clearing `end_dt=None` on update collapses to point event
- New: list_events surfaces a point event in the upcoming window
- New: list_events excludes a timed event whose effective end has
  already passed
- Existing mock-event helper updated to use `duration_minutes`
  instead of stored `end_dt`.

44 event-related tests pass; ruff clean.

## Out of scope (separate task)

Fable #161 — `find_events_by_query` returning multiple matches and
silently picking matches[0]. The exact root cause of how event id=2
got mutated in the first place; orthogonal to the storage model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:19:44 -04:00
bvandeusen b7e7073425 Revert "fix(events): tolerate corrupt end_dt + reject end<=start at write time"
This reverts commit 94b169f31c.
2026-04-29 14:18:01 -04:00
bvandeusen 94b169f31c fix(events): tolerate corrupt end_dt + reject end<=start at write time
A prod event surfaced today with `start_dt=2026-05-01T12:00Z` and
`end_dt=2026-03-30T12:00Z` — end was 32 days BEFORE start, almost
certainly from an earlier tool-call mishap (Fable #161). The
list_events filter trusted the bogus end_dt and excluded the event
from every read path that hit the upcoming window, even though
start_dt was correctly in range. The event stayed visible in the
calendar grid (different range) but vanished from "Upcoming",
search, briefings, and journal prep events list.

This is the hotfix half of the response. The structural follow-up is
Fable #160 — replace end_dt with a duration column so invalid state
becomes inexpressible.

## A. Filter robustness in list_events

Treat `end_dt <= start_dt` as if no end_dt exists. The filter now
splits into two branches:
- valid duration: end_dt IS NOT NULL AND end_dt > start_dt AND
  end_dt >= date_from
- no/invalid duration: (end_dt IS NULL OR end_dt <= start_dt) AND
  start_dt >= date_from

Same change applied to the recurring-event expansion's `duration`
calculation, which was producing negative timedeltas for corrupted
rows and computing nonsensical occurrence end times.

## B. Write-side validation in create/update

`create_event` and `update_event` now raise ValueError when the
resulting state would have end_dt <= start_dt. Update validates
against the *post-update* state, not just the field being changed —
so pushing start_dt past an existing end_dt also fails loudly. Bad
data shouldn't be persistable from any write path.

Surfaced cleanly:
- Calendar tool wrappers (create_event_tool / update_event_tool)
  catch ValueError and return `{success: false, error: ...}`, which
  the model can read and self-correct.
- Route handlers (POST /api/events, PATCH /api/events/<id>) catch
  and return HTTP 400 with the validator's message instead of
  letting it bubble to a 500.

4 new tests in test_events_service.py:
- create rejects end before start
- create rejects equal start/end (zero duration)
- update validates the post-update state (start pushed past existing end)
- list_events surfaces an event whose end_dt is before its start_dt

34 event-related tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 13:48:28 -04:00
bvandeusen 2db23cec7a refactor(events): turn EventSlideOver into a centered modal with auto-save
User feedback: the right-edge slide-over panel pinned its action buttons
to a thin floor band where the destructive ghost-style Delete button was
functionally invisible against the dark surface. Save / Cancel / Delete
all sat in the same floor strip, isolated from the form content.

This refactor changes the surface and the commit model.

## Centered modal, not slide-over
Backdrop dim covers the whole viewport; the panel sits centered with a
12px corner radius and a soft shadow. The form scrolls internally when
content overflows the viewport (max-height: calc(100vh - 2.5rem)).
File kept as `EventSlideOver.vue` to avoid touching the three consumers
(CalendarView, HomeView, ToolCallCard).

## Action buttons removed; close = save
- Save button: gone. Auto-save fires when the user closes via X, Esc,
  backdrop click, or pressing Enter inside a text field.
- Cancel button: gone. Esc / X / backdrop click already cover dismiss;
  a labeled "Cancel" was redundant.
- Delete button: moved to the header as a Trash2 icon (edit mode only).
  Click → header swaps to inline confirm "Delete this event? [Yes,
  delete] [No]" — same two-step flow, just relocated. Esc during the
  confirm cancels back to edit mode rather than closing the modal,
  giving the user a clear way out of the destructive prompt.

## Validity-aware close
All exit paths funnel through `attemptClose`:
  - Form valid → save (POST or PATCH), then close.
  - Form invalid in EDIT mode → discard the in-memory change and
    close, with a toast naming the missing field
    ("Title required — change discarded"). Keeps the user from
    silently corrupting an event.
  - Form invalid in CREATE mode → close silently. Nothing was
    committed; calling that out adds noise.

Emit signature unchanged (close / created / updated / deleted), so the
three consumers continue to work without edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 13:14:17 -04:00
10 changed files with 1023 additions and 260 deletions
@@ -0,0 +1,73 @@
"""replace events.end_dt with duration_minutes (Fable #160)
Revision ID: 0043
Revises: 0042
Create Date: 2026-04-29
Structural fix for the "end before start" bug class observed on prod
2026-04-29: an event landed with end_dt 32 days before start_dt due
to a tool-call mishap, then disappeared from upcoming-list filters.
Storing duration instead of end_dt makes the invalid state
inexpressible at the schema level (duration_minutes >= 0).
Backfill rules:
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
or open-ended; treated as a point event from here on)
Existing API consumers continue to receive `end_dt` in responses — the
field is now derived from `start_dt + duration_minutes` in
``Event.to_dict()`` rather than stored.
"""
from alembic import op
import sqlalchemy as sa
revision = "0043"
down_revision = "0042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"events",
sa.Column("duration_minutes", sa.Integer(), nullable=True),
)
op.create_check_constraint(
"events_duration_minutes_non_negative",
"events",
"duration_minutes IS NULL OR duration_minutes >= 0",
)
# Backfill: convert valid end_dt into a minute count; leave NULL for
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
# cleanly to point events instead of forcing a recovery guess.
op.execute(
"""
UPDATE events
SET duration_minutes = CAST(
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
)
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
"""
)
op.drop_column("events", "end_dt")
def downgrade() -> None:
op.add_column(
"events",
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
)
# Restore end_dt = start_dt + duration_minutes minutes for rows that
# had a duration. NULL duration → NULL end_dt (point event).
op.execute(
"""
UPDATE events
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
WHERE duration_minutes IS NOT NULL
"""
)
op.drop_constraint("events_duration_minutes_non_negative", "events")
op.drop_column("events", "duration_minutes")
+207 -154
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
import { Trash2, X } from "lucide-vue-next";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
@@ -177,26 +177,69 @@ watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") emit("close");
if (e.key === "Escape") {
if (deleteConfirm.value) {
// Esc cancels the delete-confirm rather than closing the modal —
// gives the user a clear way out of the destructive prompt.
deleteConfirm.value = false;
return;
}
attemptClose();
}
}
onMounted(() => document.addEventListener("keydown", handleKeydown));
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
async function save() {
// ── Close / save flow ─────────────────────────────────────────────────────────
//
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
//
// Validity-aware behavior:
// - Form valid → save (PATCH for edit, POST for create), then close.
// - Form invalid in EDIT mode → discard the in-memory change and close.
// A toast tells the user what happened so they don't think their edit
// silently landed.
// - Form invalid in CREATE mode → close silently (nothing existed to begin
// with; no need to call this out).
function isFormValid(): { valid: boolean; reason?: string } {
if (!title.value.trim()) {
toast.show("Title is required", "error");
return;
return { valid: false, reason: "Title required" };
}
if (!startDate.value) {
toast.show("Start date is required", "error");
return;
return { valid: false, reason: "Start date required" };
}
if (!allDay.value && !startTime.value) {
toast.show("Start time is required", "error");
return;
return { valid: false, reason: "Start time required" };
}
return { valid: true };
}
let _closing = false;
async function attemptClose() {
if (_closing) return;
_closing = true;
try {
const validity = isFormValid();
if (!validity.valid) {
if (isEditMode.value) {
toast.show(`${validity.reason} — change discarded`, "warning");
}
// Create mode + invalid: silent close. Nothing was committed.
emit("close");
return;
}
await save();
emit("close");
} finally {
_closing = false;
}
}
async function save() {
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
@@ -216,7 +259,6 @@ async function save() {
project_id: projectId.value ?? undefined,
};
const updated = await updateEvent(props.event.id, payload);
toast.show("Event updated", "success");
emit("updated", updated);
} else {
const payload: EventCreatePayload = {
@@ -230,7 +272,6 @@ async function save() {
project_id: projectId.value ?? undefined,
};
const created = await createEvent(payload);
toast.show("Event created", "success");
emit("created", created);
}
} catch {
@@ -256,23 +297,57 @@ async function doDelete() {
<template>
<Teleport to="body">
<div class="slide-over-backdrop" @click.self="emit('close')">
<div class="slide-over-panel" role="dialog" aria-modal="true">
<div class="so-header">
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
<button class="so-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
<div class="modal-backdrop" @click.self="attemptClose">
<div class="modal-panel" role="dialog" aria-modal="true">
<!-- Header: trash + close (or inline delete-confirm) -->
<div class="modal-header">
<template v-if="!deleteConfirm">
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
<div class="header-actions">
<button
v-if="isEditMode"
class="header-btn header-btn-danger"
@click="deleteConfirm = true"
title="Delete event"
aria-label="Delete event"
><Trash2 :size="16" /></button>
<button
class="header-btn"
@click="attemptClose"
title="Close"
aria-label="Close"
><X :size="16" /></button>
</div>
</template>
<template v-else>
<span class="delete-confirm-prompt">Delete this event?</span>
<div class="header-actions">
<button
type="button"
class="btn-danger"
:disabled="deleting"
@click="doDelete"
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
<button
type="button"
class="btn-confirm-cancel"
@click="deleteConfirm = false"
>No</button>
</div>
</template>
</div>
<form class="so-form" @submit.prevent="save">
<!-- Body: form (scrolls if it gets long) -->
<form class="modal-form" @submit.prevent="attemptClose">
<!-- Title -->
<div class="so-field">
<label class="so-label">Title <span class="required">*</span></label>
<input v-model="title" class="so-input" placeholder="Event title" autofocus />
<div class="form-field">
<label class="form-label">Title <span class="required">*</span></label>
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
</div>
<!-- All-day toggle -->
<div class="so-field so-field-row">
<label class="so-label so-label-inline">All day</label>
<div class="form-field form-field-row">
<label class="form-label form-label-inline">All day</label>
<button
type="button"
:class="['toggle-btn', { active: allDay }]"
@@ -281,74 +356,55 @@ async function doDelete() {
</div>
<!-- Start -->
<div class="so-field">
<label class="so-label">Start <span class="required">*</span></label>
<div class="form-field">
<label class="form-label">Start <span class="required">*</span></label>
<div class="dt-row">
<input v-model="startDate" type="date" class="so-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="so-input dt-time" required />
<input v-model="startDate" type="date" class="form-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
</div>
<p v-if="isPastEvent" class="so-past-hint">This event is in the past</p>
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
</div>
<!-- End -->
<div class="so-field">
<label class="so-label">End</label>
<div class="form-field">
<label class="form-label">End</label>
<div class="dt-row">
<input v-model="endDate" type="date" class="so-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="so-input dt-time" />
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
</div>
</div>
<!-- Location -->
<div class="so-field">
<label class="so-label">Location <span class="so-hint">(optional)</span></label>
<input v-model="location" class="so-input" placeholder="Location" />
<div class="form-field">
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
<input v-model="location" class="form-input" placeholder="Location" />
</div>
<!-- Description -->
<div class="so-field">
<label class="so-label">Description <span class="so-hint">(optional)</span></label>
<textarea v-model="description" class="so-input so-textarea" placeholder="Description" rows="3" />
<div class="form-field">
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
</div>
<!-- Color -->
<div class="so-field so-field-row">
<label class="so-label so-label-inline">Color</label>
<div class="form-field form-field-row">
<label class="form-label form-label-inline">Color</label>
<div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="so-input color-hex" placeholder="#5B4A8A" />
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
</div>
</div>
<!-- Project -->
<div class="so-field">
<label class="so-label">Project <span class="so-hint">(optional)</span></label>
<div class="form-field">
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
<ProjectSelector v-model="projectId" />
</div>
<!-- Actions -->
<div class="so-actions">
<button type="submit" class="btn-primary" :disabled="saving">
{{ saving ? "Saving…" : (isEditMode ? "Save" : "Create") }}
</button>
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
<template v-if="isEditMode">
<button
v-if="!deleteConfirm"
type="button"
class="btn-danger-ghost"
@click="deleteConfirm = true"
>Delete</button>
<template v-else>
<span class="delete-confirm-label">Delete this event?</span>
<button type="button" class="btn-danger" :disabled="deleting" @click="doDelete">
{{ deleting ? "Deleting" : "Yes, delete" }}
</button>
<button type="button" class="btn-secondary" @click="deleteConfirm = false">No</button>
</template>
</template>
</div>
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
matching the no-explicit-Save-button intent: Enter commits. -->
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
</form>
</div>
</div>
@@ -356,81 +412,110 @@ async function doDelete() {
</template>
<style scoped>
.slide-over-backdrop {
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
background: rgba(0, 0, 0, 0.55);
z-index: 200;
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.slide-over-panel {
.modal-panel {
background: var(--color-surface, #1a1b1e);
border-left: 1px solid var(--color-border, #2a2b30);
width: min(440px, 100vw);
height: 100%;
overflow-y: auto;
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
width: min(480px, 100%);
max-height: calc(100vh - 2.5rem);
display: flex;
flex-direction: column;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.so-header {
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
gap: 0.75rem;
padding: 0.85rem 1rem 0.85rem 1.5rem;
border-bottom: 1px solid var(--color-border, #2a2b30);
position: sticky;
top: 0;
background: var(--color-surface, #1a1b1e);
z-index: 1;
min-height: 3rem;
}
.so-title {
.modal-title {
font-size: 1.05rem;
font-weight: 500;
margin: 0;
color: var(--color-text, #e8e9f0);
}
.so-close {
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.header-btn {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem 0.4rem;
border-radius: 4px;
padding: 0.4rem;
border-radius: 6px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.header-btn:hover {
background: var(--color-hover, rgba(255,255,255,0.06));
color: var(--color-text, #e8e9f0);
}
.so-close:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
.so-form {
padding: 1.25rem 1.5rem;
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
than Save would have been — destructive actions shouldn't loom. */
.header-btn-danger:hover {
background: var(--color-action-destructive);
color: #fff;
}
/* Inline delete-confirm prompt replaces the title row */
.delete-confirm-prompt {
font-size: 0.95rem;
color: var(--color-text, #e8e9f0);
font-weight: 500;
}
/* Form scrolls inside the panel when content overflows */
.modal-form {
padding: 1.25rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
flex: 1;
overflow-y: auto;
}
.so-field { display: flex; flex-direction: column; gap: 0.35rem; }
.so-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
.so-label {
.form-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.so-label-inline { flex-shrink: 0; margin: 0; }
.so-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
.form-label-inline { flex-shrink: 0; margin: 0; }
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
.required { color: #f87171; }
.so-input {
.form-input {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text, #e8e9f0);
@@ -441,14 +526,14 @@ async function doDelete() {
box-sizing: border-box;
transition: border-color 0.15s;
}
.so-input:focus { outline: none; border-color: var(--color-primary); }
.form-input:focus { outline: none; border-color: var(--color-primary); }
.so-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; }
.so-past-hint {
.form-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
@@ -482,65 +567,14 @@ async function doDelete() {
font-size: 0.85rem;
}
.so-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #2a2b30);
margin-top: auto;
}
/* Save (in slide-over): Moss action-primary */
.btn-primary {
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.2rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
/* Cancel: Bronze action-secondary */
.btn-secondary {
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--color-action-secondary-hover); }
/* Delete (entry-point): Oxblood action-destructive ghost */
.btn-danger-ghost {
margin-left: auto;
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-danger-ghost:hover { background: var(--color-action-destructive); color: #fff; }
/* Confirm-delete: Oxblood filled */
/* Confirm-delete buttons (only shown during the inline confirm flow) */
.btn-danger {
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
@@ -548,9 +582,28 @@ async function doDelete() {
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.delete-confirm-label {
.btn-confirm-cancel {
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
color: var(--color-text-muted, #888);
align-self: center;
cursor: pointer;
transition: background 0.15s;
}
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
path as X / Esc / backdrop. No visible Save button needed. */
.hidden-submit {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
</style>
+22 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
@@ -20,7 +20,11 @@ class Event(Base):
uid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
start_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True))
end_dt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Duration in minutes; NULL = point event with no end specified.
# Replaces the prior `end_dt` column (Fable #160 / migration 0043).
# The DB has a CHECK constraint that this is NULL or >= 0, so an
# event whose end is before its start is structurally inexpressible.
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(Text, default="")
@@ -38,7 +42,21 @@ class Event(Base):
onupdate=lambda: datetime.now(timezone.utc),
)
@property
def end_dt(self) -> datetime | None:
"""Derived end datetime: ``start_dt + duration_minutes``.
Returns ``None`` for point events (``duration_minutes is None``).
Computed at access time rather than stored — a stored end was
the source of the "end before start" corruption that motivated
this redesign.
"""
if self.duration_minutes is None:
return None
return self.start_dt + timedelta(minutes=self.duration_minutes)
def to_dict(self) -> dict:
end_dt = self.end_dt
return {
"id": self.id,
"user_id": self.user_id,
@@ -47,7 +65,8 @@ class Event(Base):
"project_id": self.project_id,
"title": self.title,
"start_dt": self.start_dt.isoformat() if self.start_dt else None,
"end_dt": self.end_dt.isoformat() if self.end_dt else None,
"end_dt": end_dt.isoformat() if end_dt else None,
"duration_minutes": self.duration_minutes,
"all_day": self.all_day,
"description": self.description,
"location": self.location,
+26 -19
View File
@@ -54,19 +54,23 @@ async def create_event():
end_dt = _parse_dt(data["end_dt"]) if data.get("end_dt") else None
except ValueError:
return jsonify({"error": "Invalid datetime format"}), 400
event = await events_svc.create_event(
user_id=_get_current_user_id(),
title=data["title"],
start_dt=start_dt,
end_dt=end_dt,
all_day=data.get("all_day", False),
description=data.get("description", ""),
location=data.get("location", ""),
color=data.get("color", ""),
recurrence=data.get("recurrence"),
project_id=data.get("project_id"),
reminder_minutes=data.get("reminder_minutes"),
)
try:
event = await events_svc.create_event(
user_id=_get_current_user_id(),
title=data["title"],
start_dt=start_dt,
end_dt=end_dt,
duration_minutes=data.get("duration_minutes"),
all_day=data.get("all_day", False),
description=data.get("description", ""),
location=data.get("location", ""),
color=data.get("color", ""),
recurrence=data.get("recurrence"),
project_id=data.get("project_id"),
reminder_minutes=data.get("reminder_minutes"),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(event.to_dict()), 201
@@ -93,7 +97,7 @@ async def update_event(event_id: int):
for bool_field in ("all_day",):
if bool_field in data:
fields[bool_field] = data[bool_field]
for int_field in ("project_id", "reminder_minutes"):
for int_field in ("project_id", "reminder_minutes", "duration_minutes"):
if int_field in data:
fields[int_field] = data[int_field]
for dt_field in ("start_dt", "end_dt"):
@@ -106,11 +110,14 @@ async def update_event(event_id: int):
fields[dt_field] = _parse_dt(data[dt_field])
except ValueError:
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
event = await events_svc.update_event(
user_id=_get_current_user_id(),
event_id=event_id,
**fields,
)
try:
event = await events_svc.update_event(
user_id=_get_current_user_id(),
event_id=event_id,
**fields,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if event is None:
return jsonify({"error": "Event not found"}), 404
return jsonify(event.to_dict())
+14 -3
View File
@@ -130,6 +130,17 @@ async def sync_user_events(user_id: int) -> dict:
async with async_session() as session:
for ev in remote_events:
caldav_uid = ev["caldav_uid"]
# Storage uses duration, not end_dt. Convert here so the
# rest of this function can compare/upsert in one shape.
ev_start = ev["start_dt"]
ev_end = ev["end_dt"]
ev_duration = (
int((ev_end - ev_start).total_seconds() // 60)
if ev_end is not None and ev_start is not None and ev_end > ev_start
else None
)
ev["duration_minutes"] = ev_duration
result = await session.execute(
select(Event).where(
Event.user_id == user_id,
@@ -145,8 +156,8 @@ async def sync_user_events(user_id: int) -> dict:
uid=str(uuid.uuid4()),
caldav_uid=caldav_uid,
title=ev["title"],
start_dt=ev["start_dt"],
end_dt=ev["end_dt"],
start_dt=ev_start,
duration_minutes=ev_duration,
all_day=ev["all_day"],
description=ev["description"],
location=ev["location"],
@@ -157,7 +168,7 @@ async def sync_user_events(user_id: int) -> dict:
else:
# Update if anything changed
changed = False
for field in ("title", "start_dt", "end_dt", "all_day", "description", "location", "recurrence"):
for field in ("title", "start_dt", "duration_minutes", "all_day", "description", "location", "recurrence"):
if getattr(existing, field) != ev[field]:
setattr(existing, field, ev[field])
changed = True
+10 -2
View File
@@ -207,6 +207,14 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
d = dtend.dt
end_dt = datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
# Storage uses duration, not end_dt. Convert iCal DTEND to a
# minute count anchored on DTSTART. Treat invalid (end <= start)
# incoming data as a point event rather than rejecting; we
# don't control external CalDAV writers.
duration_minutes = None
if end_dt is not None and end_dt > start_dt:
duration_minutes = int((end_dt - start_dt).total_seconds() // 60)
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.user_id == user_id, Event.uid == ical_uid)
@@ -215,7 +223,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
if existing:
existing.title = title
existing.start_dt = start_dt
existing.end_dt = end_dt
existing.duration_minutes = duration_minutes
existing.all_day = all_day
existing.description = description
existing.location = location
@@ -230,7 +238,7 @@ async def sync_event_to_db(user_id: int, ical_uid: str) -> Event | None:
uid=ical_uid,
title=title,
start_dt=start_dt,
end_dt=end_dt,
duration_minutes=duration_minutes,
all_day=all_day,
description=description,
location=location,
+171 -59
View File
@@ -1,4 +1,13 @@
"""Internal event store service with CalDAV push sync."""
"""Internal event store service with CalDAV push sync.
Storage model: an event is anchored at ``start_dt`` and has an optional
``duration_minutes``. The end of the event is *derived* via
``Event.end_dt`` (a Python property), never stored. Callers may still
pass ``end_dt`` on writes for ergonomic compatibility — the service
converts to ``duration_minutes`` internally. This rules out the entire
"end before start" bug class structurally (Fable #160 / migration
0043). Open-ended events use ``duration_minutes = None``.
"""
from __future__ import annotations
import asyncio
@@ -7,7 +16,7 @@ import uuid
from datetime import datetime, timedelta, timezone
from dateutil.rrule import rrulestr
from sqlalchemy import and_, or_, select
from sqlalchemy import or_, select
from fabledassistant.models import async_session
from fabledassistant.models.event import Event
@@ -15,11 +24,56 @@ from fabledassistant.models.event import Event
logger = logging.getLogger(__name__)
def _normalize_duration(
*,
start_dt: datetime,
end_dt: datetime | None,
duration_minutes: int | None,
) -> int | None:
"""Reduce (end_dt, duration_minutes) inputs to a single canonical
``duration_minutes`` value.
Resolution order:
1. If ``duration_minutes`` is explicit, use it (validate >= 0).
If ``end_dt`` is also given, validate the two agree.
2. Otherwise, derive from ``end_dt - start_dt``.
3. Otherwise None (point event with no end).
Raises ``ValueError`` for any invalid combination — duration < 0,
end_dt < start_dt, or end_dt and duration_minutes inconsistent.
"""
if duration_minutes is not None:
if duration_minutes < 0:
raise ValueError(
f"duration_minutes must be >= 0, got {duration_minutes}"
)
if end_dt is not None:
expected = int((end_dt - start_dt).total_seconds() // 60)
if expected != duration_minutes:
raise ValueError(
f"end_dt ({end_dt.isoformat()}) implies "
f"{expected} minutes but duration_minutes={duration_minutes} "
f"was passed; pass only one or make them agree."
)
return duration_minutes
if end_dt is not None:
delta_seconds = (end_dt - start_dt).total_seconds()
if delta_seconds < 0:
raise ValueError(
f"end_dt ({end_dt.isoformat()}) must be at or after "
f"start_dt ({start_dt.isoformat()}); pass end_dt=None "
f"or omit it for point events."
)
return int(delta_seconds // 60)
return None
async def create_event(
user_id: int,
title: str,
start_dt: datetime,
end_dt: datetime | None = None,
duration_minutes: int | None = None,
all_day: bool = False,
description: str = "",
location: str = "",
@@ -27,12 +81,25 @@ async def create_event(
recurrence: str | None = None,
project_id: int | None = None,
reminder_minutes: int | None = None,
# CalDAV-only fields (not stored in DB, forwarded to push)
# ``duration`` is a legacy alias kept for the calendar tool layer
# and CalDAV pass-through callers; promotes to duration_minutes
# when duration_minutes isn't otherwise specified.
duration: int | None = None,
attendees: list[str] | None = None,
calendar_name: str | None = None,
) -> Event:
"""Create an event in the DB, then fire a CalDAV push task."""
"""Create an event in the DB, then fire a CalDAV push task.
Either ``end_dt`` or ``duration_minutes`` may be supplied; the
service converts to ``duration_minutes`` internally. Raises
``ValueError`` on invalid combinations (negative duration, end
before start, end/duration disagreement).
"""
if duration is not None and duration_minutes is None:
duration_minutes = duration
duration_minutes = _normalize_duration(
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
)
uid = str(uuid.uuid4())
async with async_session() as session:
event = Event(
@@ -40,7 +107,7 @@ async def create_event(
uid=uid,
title=title,
start_dt=start_dt,
end_dt=end_dt,
duration_minutes=duration_minutes,
all_day=all_day,
description=description,
location=location,
@@ -54,7 +121,7 @@ async def create_event(
await session.refresh(event)
extra_fields = {
"duration": duration,
"duration": duration_minutes,
"reminder_minutes": reminder_minutes,
"attendees": attendees,
"calendar_name": calendar_name,
@@ -80,66 +147,74 @@ async def list_events(
"""List events for user_id that overlap [date_from, date_to].
Recurring events (with an RRULE recurrence string) are expanded into
individual occurrences within the range. Non-recurring events are
returned as-is. All results are sorted by start time and returned as
dicts (same shape as Event.to_dict()).
individual occurrences within the range. Non-recurring events are
returned as-is. All results are sorted by start time and returned as
dicts (same shape as ``Event.to_dict()``).
Filtering strategy: a coarse SQL prefilter (events that start on or
before ``date_to``), then refine in Python using the event's derived
end (``start_dt + duration_minutes``). Doing the end-of-event math
in SQL would require Postgres-specific interval arithmetic; the
Python-side refinement is a few row-loops over a small per-user
result set, which is fine for personal-scale data and avoids
coupling the query to a specific dialect.
"""
async with async_session() as session:
# Match strategy:
# - Recurring events: fetch all, expand via rrule below.
# - Non-recurring with an end_dt: standard overlap — starts before
# date_to and ends after date_from.
# - Non-recurring with no end_dt: treat as a point event at
# start_dt, include only if start_dt falls within the window.
# (Previously this branch matched any event with a null end_dt,
# returning all past events as "happening today".)
result = await session.execute(
select(Event).where(
select(Event)
.where(
Event.user_id == user_id,
or_(
Event.recurrence.isnot(None),
and_(
Event.recurrence.is_(None),
Event.start_dt <= date_to,
or_(
Event.end_dt >= date_from,
and_(
Event.end_dt.is_(None),
Event.start_dt >= date_from,
),
),
),
Event.start_dt <= date_to,
),
).order_by(Event.start_dt)
)
.order_by(Event.start_dt)
)
events = list(result.scalars().all())
items: list[dict] = []
for event in events:
if not event.recurrence:
items.append(event.to_dict())
if event.recurrence:
duration = (
timedelta(minutes=event.duration_minutes)
if event.duration_minutes is not None
else None
)
try:
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
occurrences = rule.between(date_from, date_to, inc=True)
except Exception:
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
# Fall back to canonical event row; still apply the
# window check so a far-future canonical row doesn't
# leak into today's list.
if date_from <= event.start_dt <= date_to:
items.append(event.to_dict())
continue
base = event.to_dict()
for occ in occurrences:
if occ.tzinfo is None:
occ = occ.replace(tzinfo=timezone.utc)
occurrence_dict = dict(base)
occurrence_dict["start_dt"] = occ.isoformat()
if duration is not None:
occurrence_dict["end_dt"] = (occ + duration).isoformat()
items.append(occurrence_dict)
continue
# Expand recurring event occurrences within [date_from, date_to]
duration = (event.end_dt - event.start_dt) if event.end_dt else None
try:
rule = rrulestr(event.recurrence, dtstart=event.start_dt, ignoretz=False)
occurrences = rule.between(date_from, date_to, inc=True)
except Exception:
logger.warning("Failed to expand RRULE for event %d: %r", event.id, event.recurrence)
items.append(event.to_dict())
continue
base = event.to_dict()
for occ in occurrences:
# Ensure occurrence is UTC-aware
if occ.tzinfo is None:
occ = occ.replace(tzinfo=timezone.utc)
occurrence_dict = dict(base)
occurrence_dict["start_dt"] = occ.isoformat()
if duration is not None:
occurrence_dict["end_dt"] = (occ + duration).isoformat()
items.append(occurrence_dict)
# Non-recurring: refine the coarse prefilter in Python using the
# derived end_dt. A point event (duration None) is included when
# its start is at or after date_from. A timed event is included
# when its end is at or after date_from.
derived_end = event.end_dt
if derived_end is None:
if event.start_dt >= date_from:
items.append(event.to_dict())
else:
if derived_end >= date_from:
items.append(event.to_dict())
items.sort(key=lambda x: x["start_dt"])
return items
@@ -173,7 +248,13 @@ async def search_events(
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
"""Partial update. Returns updated event or None if not found."""
"""Partial update. Returns updated event or None if not found.
Accepts ``end_dt`` or ``duration_minutes`` (or both, validated for
agreement). The service converts to ``duration_minutes`` before
persisting; ``end_dt`` is never stored. Raises ``ValueError`` for
invalid combinations against the post-update state.
"""
async with async_session() as session:
result = await session.execute(
select(Event).where(Event.id == event_id, Event.user_id == user_id)
@@ -182,10 +263,39 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
if event is None:
return None
old_title = event.title # capture before mutation for CalDAV lookup
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
"location", "color", "recurrence", "project_id", "reminder_minutes"}
# Nullable fields that callers can explicitly set to None to clear
nullable = {"end_dt", "recurrence", "project_id", "reminder_minutes"}
# Resolve any end_dt/duration_minutes inputs against the
# post-update start_dt. If neither is in the patch, leave the
# existing duration_minutes alone.
post_update_start = (
fields["start_dt"]
if fields.get("start_dt") is not None
else event.start_dt
)
if "end_dt" in fields or "duration_minutes" in fields:
new_end = fields.pop("end_dt", None)
new_duration = fields.pop("duration_minutes", None)
# If end_dt is in the patch but explicitly None, that's a
# clear → duration_minutes = None. Same shape duration_minutes=None.
if new_end is None and new_duration is None:
fields["duration_minutes"] = None
else:
fields["duration_minutes"] = _normalize_duration(
start_dt=post_update_start,
end_dt=new_end,
duration_minutes=new_duration,
)
allowed = {
"title", "start_dt", "duration_minutes", "all_day",
"description", "location", "color", "recurrence",
"project_id", "reminder_minutes",
}
# Nullable fields callers can explicitly clear by passing None
nullable = {
"duration_minutes", "recurrence", "project_id",
"reminder_minutes",
}
for key, value in fields.items():
if key in allowed and (value is not None or key in nullable):
setattr(event, key, value)
@@ -255,11 +365,12 @@ async def _push_create(event: Event, user_id: int, extra: dict) -> None:
)
if not await is_caldav_configured(user_id):
return
derived_end = event.end_dt # property: start + duration_minutes
await caldav_create(
user_id=user_id,
title=event.title,
start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None,
end=derived_end.isoformat() if derived_end else None,
description=event.description or None,
location=event.location or None,
all_day=event.all_day,
@@ -296,12 +407,13 @@ async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
return
# Use old_title so CalDAV can find the event even if the title was changed
query_title = old_title or event.title
derived_end = event.end_dt
await caldav_update(
user_id=user_id,
query=query_title,
title=event.title,
start=event.start_dt.isoformat(),
end=event.end_dt.isoformat() if event.end_dt else None,
end=derived_end.isoformat() if derived_end else None,
description=event.description or None,
location=event.location or None,
)
+117 -16
View File
@@ -108,6 +108,88 @@ async def _resolve_event_end(
return None
def _candidate_summary(event) -> dict:
"""Compact event summary used in ambiguous-match responses.
Keeps the candidate list small so the model can disambiguate from
the same turn without bloating context. Includes id (for the
follow-up call), title, start_dt, and location when present.
"""
return {
"id": event.id,
"title": event.title,
"start_dt": event.start_dt.isoformat() if event.start_dt else None,
"location": event.location or None,
}
async def _resolve_event_for_action(
*, user_id: int, arguments: dict, action: str,
):
"""Pick the single event the model intends to update or delete.
Resolution rules:
- ``event_id`` in arguments → exact lookup (skip query). Used by
the model to disambiguate after a multi-match refusal.
- else ``query`` → ``find_events_by_query``:
- 0 results → return error tuple ("not_found", ...)
- 1 result → return that event
- 2+ results → return ("ambiguous", error, candidates) so the
caller can refuse the call and show candidates to the model.
Returns either an Event (success) or a 2- or 3-tuple of
``(error_kind, error_dict)`` for the caller to translate into a
tool-call response.
"""
from fabledassistant.services.events import get_event
event_id = arguments.get("event_id")
if event_id is not None:
try:
event_id_int = int(event_id)
except (TypeError, ValueError):
return ("invalid_id", {
"success": False,
"error": f"event_id must be an integer; got {event_id!r}.",
})
ev = await get_event(user_id=user_id, event_id=event_id_int)
if ev is None:
return ("not_found", {
"success": False,
"error": f"No event found with id={event_id_int}.",
})
return ev
query = arguments.get("query", "")
if not query:
return ("invalid_query", {
"success": False,
"error": "Either query or event_id is required.",
})
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return ("not_found", {
"success": False,
"error": f"No event found matching {query!r}.",
})
if len(matches) == 1:
return matches[0]
# Multi-match: refuse and surface candidates so the model can
# disambiguate via event_id on the next call. Prevents the silent-
# picks-matches[0] failure mode that mutated the wrong event in the
# 2026-04-29 dentist-appointment incident (Fable #161).
return ("ambiguous", {
"success": False,
"error": (
f"Found {len(matches)} events matching {query!r}. "
f"Pick one by passing `event_id` instead of `query`, "
f"or refine the search term to match a single event."
),
"action": action,
"candidates": [_candidate_summary(m) for m in matches[:8]],
})
def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None:
"""Verify the resolved local date falls on the expected day of the week.
@@ -305,10 +387,17 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
"When the user names a weekday ('move to Friday'), state the "
"resolved calendar date in your reply BEFORE calling this tool, "
"and pass `expected_weekday` so the server can verify the date "
"falls on the day you intended."
"falls on the day you intended.\n\n"
"Identify the event with EITHER `query` (a title substring) OR "
"`event_id` (when you already have an exact id from a prior tool "
"result). If `query` matches multiple events, the tool returns "
"an ambiguity error with a candidate list — pick one by passing "
"its `event_id` on the next call, or refine the query so it "
"matches a single event."
),
parameters={
"query": {"type": "string", "description": "Search term to find the event to update (matches against title)"},
"query": {"type": "string", "description": "Search term to find the event to update (matches against title). Required unless event_id is set."},
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
"title": {"type": "string", "description": "New title for the event"},
"start_date": {"type": "string", "description": "New start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
"start_time": {"type": "string", "description": "New start wall-clock time as HH:MM. No timezone suffix."},
@@ -325,14 +414,15 @@ async def search_events_tool(*, user_id, arguments, **_ctx):
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
},
required=["query"],
required=[],
)
async def update_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_update = matches[0]
resolved = await _resolve_event_for_action(
user_id=user_id, arguments=arguments, action="update",
)
if isinstance(resolved, tuple):
return resolved[1] # error dict from the resolver
event_to_update = resolved
fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"):
if arguments.get(str_field) is not None:
@@ -368,18 +458,29 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
@tool(
name="delete_event",
description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
description=(
"Delete a calendar event. Use this when the user asks to cancel, "
"remove, or delete an event. Identify the event with EITHER "
"`query` (a title substring) OR `event_id` (when you have an "
"exact id). If `query` matches multiple events, the tool returns "
"an ambiguity error with a candidate list — pick one by passing "
"its `event_id` on the next call, or refine the query so it "
"matches a single event. Deleting the wrong event is a costly "
"user error; never guess between candidates."
),
parameters={
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"},
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title). Required unless event_id is set."},
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
},
required=["query"],
required=[],
)
async def delete_event_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return {"success": False, "error": f"No event found matching '{query}'."}
event_to_delete = matches[0]
resolved = await _resolve_event_for_action(
user_id=user_id, arguments=arguments, action="delete",
)
if isinstance(resolved, tuple):
return resolved[1]
event_to_delete = resolved
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}}
+182
View File
@@ -577,6 +577,188 @@ async def test_update_event_expected_weekday_mismatch_rejects():
assert update_called is False
# ── Multi-match disambiguation (Fable #161) ──────────────────────────────────
@pytest.mark.asyncio
async def test_update_event_refuses_ambiguous_query_with_candidates():
"""The reported failure: model called update_event(query='Appointment')
when two events matched. Pre-fix: silently mutated matches[0] (the
wrong event). Post-fix: returns success=False with a candidates list
so the model can pick the right one."""
from fabledassistant.services.tools.calendar import update_event_tool
# Two events both matching "Appointment"
ev_a = AsyncMock()
ev_a.id = 2
ev_a.title = "Appointment"
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
ev_a.location = ""
ev_b = AsyncMock()
ev_b.id = 15
ev_b.title = "Appointment"
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
ev_b.location = "Dentist"
update_called = False
async def fake_find(*, user_id, query):
return [ev_a, ev_b]
async def fake_update(*, user_id, event_id, **fields):
nonlocal update_called
update_called = True
return AsyncMock()
with patch(
"fabledassistant.services.tools.calendar.find_events_by_query",
side_effect=fake_find,
), patch(
"fabledassistant.services.tools.calendar.events_update_event",
side_effect=fake_update,
):
result = await update_event_tool(
user_id=1,
arguments={"query": "Appointment", "title": "Dentist"},
)
assert result["success"] is False
assert "Found 2 events" in result["error"]
assert "event_id" in result["error"].lower()
assert "candidates" in result
candidate_ids = [c["id"] for c in result["candidates"]]
assert candidate_ids == [2, 15]
# Critical: nothing was mutated.
assert update_called is False
@pytest.mark.asyncio
async def test_update_event_with_event_id_skips_query_lookup():
"""Once the model picks a candidate via event_id, the call proceeds
against that exact event — no query, no ambiguity."""
from fabledassistant.services.tools.calendar import update_event_tool
# find_events_by_query should NOT be called when event_id is supplied
find_called = False
async def fake_find(*, user_id, query):
nonlocal find_called
find_called = True
return []
target = AsyncMock()
target.id = 15
target.title = "Appointment"
target.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
target.location = ""
async def fake_get_event(*, user_id, event_id):
return target if event_id == 15 else None
captured = {}
async def fake_update(*, user_id, event_id, **fields):
captured["event_id"] = event_id
captured.update(fields)
ev = AsyncMock()
ev.to_dict.return_value = {"id": event_id, **fields}
return ev
with patch(
"fabledassistant.services.tools.calendar.find_events_by_query",
side_effect=fake_find,
), patch(
"fabledassistant.services.events.get_event",
side_effect=fake_get_event,
), patch(
"fabledassistant.services.tools.calendar.events_update_event",
side_effect=fake_update,
):
result = await update_event_tool(
user_id=1,
arguments={"event_id": 15, "title": "Dentist: Permanent Crown Fitting"},
)
assert result["success"] is True
assert captured["event_id"] == 15
assert captured["title"] == "Dentist: Permanent Crown Fitting"
assert find_called is False
@pytest.mark.asyncio
async def test_update_event_with_event_id_not_found_returns_error():
"""A bad event_id (non-existent) must surface clearly, not silently
fall back to query-based lookup."""
from fabledassistant.services.tools.calendar import update_event_tool
async def fake_get_event(*, user_id, event_id):
return None
with patch(
"fabledassistant.services.events.get_event",
side_effect=fake_get_event,
):
result = await update_event_tool(
user_id=1,
arguments={"event_id": 999, "title": "anything"},
)
assert result["success"] is False
assert "999" in result["error"]
assert "No event found" in result["error"]
@pytest.mark.asyncio
async def test_update_event_neither_query_nor_event_id_returns_error():
"""Calling update_event with neither identifier is a usage error."""
from fabledassistant.services.tools.calendar import update_event_tool
result = await update_event_tool(user_id=1, arguments={"title": "x"})
assert result["success"] is False
assert "query or event_id" in result["error"]
@pytest.mark.asyncio
async def test_delete_event_refuses_ambiguous_query_with_candidates():
"""delete_event must enforce the same disambiguation. Costly to get
wrong — silent matches[0] would delete the wrong event entirely."""
from fabledassistant.services.tools.calendar import delete_event_tool
ev_a = AsyncMock()
ev_a.id = 2; ev_a.title = "Appointment"
ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
ev_a.location = ""
ev_b = AsyncMock()
ev_b.id = 15; ev_b.title = "Appointment"
ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
ev_b.location = "Dentist"
delete_called = False
async def fake_find(*, user_id, query):
return [ev_a, ev_b]
async def fake_delete(*, user_id, event_id):
nonlocal delete_called
delete_called = True
with patch(
"fabledassistant.services.tools.calendar.find_events_by_query",
side_effect=fake_find,
), patch(
"fabledassistant.services.tools.calendar.events_delete_event",
side_effect=fake_delete,
):
result = await delete_event_tool(
user_id=1, arguments={"query": "Appointment"},
)
assert result["success"] is False
assert len(result["candidates"]) == 2
# Most important assertion: nothing was actually deleted.
assert delete_called is False
@pytest.mark.asyncio
async def test_create_event_weekday_check_uses_local_not_utc():
"""The weekday check must use the LOCAL date, not the UTC date.
+201 -4
View File
@@ -14,7 +14,7 @@ def _make_mock_session():
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
caldav_uid="", color=""):
caldav_uid="", color="", duration_minutes=60):
e = MagicMock()
e.id = id
e.user_id = user_id
@@ -23,7 +23,14 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
e.caldav_uid = caldav_uid
e.color = color
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc)
e.duration_minutes = duration_minutes
# end_dt is derived; mirror the property's behavior on the mock so
# service code that reads `event.end_dt` gets a sensible value.
if duration_minutes is None:
e.end_dt = None
else:
from datetime import timedelta
e.end_dt = e.start_dt + timedelta(minutes=duration_minutes)
e.all_day = False
e.description = ""
e.location = ""
@@ -32,8 +39,9 @@ def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
e.to_dict.return_value = {
"id": id, "uid": uid, "title": title,
"caldav_uid": caldav_uid, "color": color,
"start_dt": datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc).isoformat(),
"end_dt": datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc).isoformat(),
"start_dt": e.start_dt.isoformat(),
"end_dt": e.end_dt.isoformat() if e.end_dt else None,
"duration_minutes": duration_minutes,
}
return e
@@ -124,6 +132,195 @@ async def test_update_event_fires_caldav_push():
assert mock_task.called
# ── Duration-model write-side guarantees (Fable #160) ─────────────────────────
def test_normalize_duration_from_end_dt():
"""end_dt sugar converts to a positive minute count anchored on start."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90
def test_normalize_duration_zero_is_valid_point_event():
"""end_dt == start_dt → duration 0. The point-with-zero-duration case
is rare but legal (e.g. an instant marker); the duration model treats
it the same as duration None for display purposes."""
from fabledassistant.services.events import _normalize_duration
same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc)
assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0
def test_normalize_duration_rejects_end_before_start():
"""The exact 2026-04-29 prod failure: end 32 days before start.
The duration model makes this inexpressible at the schema level
via a CHECK constraint, but write-path callers still get a
helpful ValueError if they construct an inconsistent (start, end)
pair via the end_dt sugar."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
_normalize_duration(
start_dt=start, end_dt=end_before, duration_minutes=None,
)
def test_normalize_duration_rejects_negative_duration():
"""Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK
constraint at the service boundary so callers get a clean error
rather than a constraint violation from psycopg."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="must be >= 0"):
_normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15)
def test_normalize_duration_rejects_inconsistent_end_and_duration():
"""If a caller passes both end_dt AND duration_minutes that disagree,
the inconsistency is surfaced rather than silently picking one."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min
with pytest.raises(ValueError, match="implies 60 minutes"):
_normalize_duration(
start_dt=start, end_dt=end, duration_minutes=30,
)
def test_normalize_duration_none_for_open_ended():
"""Both inputs None → None duration (open-ended event)."""
from fabledassistant.services.events import _normalize_duration
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
assert _normalize_duration(
start_dt=start, end_dt=None, duration_minutes=None,
) is None
@pytest.mark.asyncio
async def test_create_event_rejects_end_before_start():
"""Service-level rejection — same scenario as the prod bug, surfaced
cleanly for tool / route callers via ValueError."""
from fabledassistant.services.events import create_event
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="at or after start_dt"):
await create_event(
user_id=1, title="Bad",
start_dt=start, end_dt=end_before,
)
@pytest.mark.asyncio
async def test_update_event_preserves_duration_when_only_start_changes():
"""Sliding semantics: when the user moves an event by changing only
start_dt, the existing duration_minutes is preserved as-is. The new
effective end_dt slides forward with the start. This is a behavioral
upgrade vs. the old end_dt model, where moving start past the
stored end made the event 'go backward in time'."""
mock_event = _make_mock_event(duration_minutes=60) # start 10:00, end 11:00
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls, \
patch("fabledassistant.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from fabledassistant.services.events import update_event
# Move start to 12:00; effective end becomes 13:00 automatically.
result = await update_event(
user_id=1, event_id=1,
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
)
assert result is not None
# duration_minutes was NOT touched; mock_event still has 60.
assert mock_event.duration_minutes == 60
@pytest.mark.asyncio
async def test_update_event_clearing_end_dt_clears_duration():
"""Passing end_dt=None on update is the documented way to clear the
end (turn a timed event into a point event). The service must
translate that into duration_minutes=None, not leave the prior
value in place."""
mock_event = _make_mock_event(duration_minutes=60)
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls, \
patch("fabledassistant.services.events.asyncio.create_task"):
mock_cls.return_value = mock_session
from fabledassistant.services.events import update_event
await update_event(user_id=1, event_id=1, end_dt=None)
assert mock_event.duration_minutes is None
@pytest.mark.asyncio
async def test_list_events_includes_point_event_in_window():
"""A point event (duration_minutes=None) surfaces when its start
is in the window. Replaces the prior 'corrupt end_dt' regression
test — the duration model can't represent that state, but the
same code path is exercised here for point events."""
mock_event = _make_mock_event(duration_minutes=None)
# Point event in the upcoming window
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = None
mock_event.to_dict.return_value = {
"id": 1, "title": "Point",
"start_dt": mock_event.start_dt.isoformat(),
"end_dt": None,
"duration_minutes": None,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert len(results) == 1
assert results[0]["id"] == 1
@pytest.mark.asyncio
async def test_list_events_excludes_timed_event_that_already_ended():
"""A timed event whose start + duration is before the window must
NOT surface. Verifies the Python-side refinement actually works
against the coarse SQL prefilter."""
mock_event = _make_mock_event(duration_minutes=60)
# Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past.
mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc)
mock_event.to_dict.return_value = {
"id": 1, "start_dt": mock_event.start_dt.isoformat(),
"end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60,
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import list_events
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
assert results == []
@pytest.mark.asyncio
async def test_tools_calendar_always_available():
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""