feat: reconcile tentative calendar holds

This commit is contained in:
2026-08-20 07:44:15 +02:00
parent fb8d674637
commit 79cfaa951a
7 changed files with 522 additions and 29 deletions
+4 -1
View File
@@ -147,6 +147,9 @@ DOCUMENTATION = (
body=(
"Calendar coordination remains optional. It is available only when Calendar contributes its picker capability and the actor can read calendars and availability and write events. "
"A disabled Calendar control therefore names the missing integration or authority instead of silently accepting a configuration that cannot run. "
"At decision time, a selected tentative hold is promoted in place while every unused hold is submitted to Calendar for durable release. Cancellation likewise waits until Calendar accepts every release. "
"Partial or unavailable Calendar effects keep the Scheduling lifecycle incomplete and expose retry-required cleanup state; an exact retry reuses the recorded event and operation identities instead of duplicating effects. "
"Administrators recover synchronized failures through Calendar's outbound-change reconciliation and then repeat the Scheduling action. Scheduling stores only operation identifiers, last-known state, and pending slot references; Calendar remains authoritative for event and outbox evidence. "
"Administrators should enable the Calendar module and grant the bounded calendar, availability, and event permissions needed by the organizer; Scheduling never imports Calendar internals."
),
layer="configured",
@@ -290,7 +293,7 @@ manifest = ModuleManifest(
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.9", version_max_exclusive="0.2.0", optional=True),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
+7 -1
View File
@@ -739,7 +739,13 @@ def api_cancel_scheduling_request(
) -> SchedulingStatusResponse:
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
request = cancel_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
user_id=principal.account_id,
allow_calendar_cleanup=has_scope(principal, CALENDAR_EVENT_WRITE_SCOPE),
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
+289 -20
View File
@@ -623,7 +623,6 @@ def create_tentative_calendar_holds(
slot.metadata_ = {
**(slot.metadata_ or {}),
"calendar_hold": {
"event_id": event.id,
# This is a creation-time snapshot. Calendar remains the
# authority for later outbox delivery transitions.
"last_known_external_state": event.external_state,
@@ -635,6 +634,104 @@ def create_tentative_calendar_holds(
return request, created_event_ids, warnings
def _calendar_cleanup_metadata(
slot: SchedulingCandidateSlot,
*,
state: str,
operation_id: str | None = None,
) -> None:
slot.metadata_ = {
**(slot.metadata_ or {}),
"calendar_hold": {
"last_known_external_state": state,
"outbox_operation_id": operation_id,
},
}
def _release_tentative_calendar_holds(
session: Session,
*,
request: SchedulingRequest,
user_id: str | None,
include_selected: bool,
) -> list[str]:
candidates = [
slot
for slot in _active_slots(request)
if slot.tentative_hold_event_id
and (include_selected or slot.id != request.selected_slot_id)
]
if not candidates:
return []
try:
provider = _calendar_provider()
except SchedulingError as exc:
for slot in candidates:
_calendar_cleanup_metadata(slot, state="retry_required")
return [str(exc)]
warnings: list[str] = []
for slot in candidates:
event_id = slot.tentative_hold_event_id
if not event_id:
continue
try:
released = provider.release_event(
session,
tenant_id=request.tenant_id,
user_id=user_id,
event_id=event_id,
)
except CalendarCapabilityError as exc:
_calendar_cleanup_metadata(slot, state="retry_required")
warnings.append(f"Slot {slot.id}: {exc}")
continue
if not released.accepted:
_calendar_cleanup_metadata(
slot,
state=released.external_state or "retry_required",
operation_id=released.outbox_operation_id,
)
warnings.append(f"Slot {slot.id}: Calendar did not accept the release operation")
continue
_calendar_cleanup_metadata(
slot,
state=released.external_state,
operation_id=released.outbox_operation_id,
)
slot.tentative_hold_event_id = None
session.flush()
return warnings
def _record_calendar_cleanup_state(
request: SchedulingRequest,
*,
warnings: list[str],
target_state: str | None = None,
) -> None:
current_cleanup = (request.metadata_ or {}).get("calendar_cleanup")
inherited_target = (
current_cleanup.get("target_state")
if isinstance(current_cleanup, dict)
else None
)
pending_slot_ids = [
slot.id
for slot in _active_slots(request)
if slot.tentative_hold_event_id
]
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_cleanup": {
"status": "retry_required" if warnings or pending_slot_ids else "accepted",
"target_state": target_state or inherited_target,
"pending_slot_ids": pending_slot_ids,
"warnings": warnings[:20],
},
}
def create_final_calendar_event(
session: Session,
*,
@@ -646,53 +743,120 @@ def create_final_calendar_event(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_slots=True,
)
_require_calendar_enabled(request)
if not request.create_calendar_event_on_decision:
raise SchedulingError("Final calendar event creation is not enabled for this scheduling request")
if request.status == "handed_off" and request.selected_slot_id and request.calendar_event_id:
if (
request.status == "handed_off"
and request.selected_slot_id
and request.calendar_event_id
and not any(slot.tentative_hold_event_id for slot in _active_slots(request))
):
return request, request.calendar_event_id, []
if request.status != "decided" or not request.selected_slot_id:
if request.status not in {"decided", "handed_off"} or not request.selected_slot_id:
raise SchedulingError(
"A final calendar event can only be created for a decided scheduling request with a selected slot"
)
if request.calendar_event_id:
return request, request.calendar_event_id, []
slot = _selected_slot(request, slot_id=request.selected_slot_id)
provider = _calendar_provider()
try:
event = provider.create_event(
warnings: list[str] = []
event = None
if not request.calendar_event_id:
try:
if slot.tentative_hold_event_id:
event = provider.promote_event(
session,
tenant_id=tenant_id,
user_id=user_id,
event_id=slot.tentative_hold_event_id,
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
)
_calendar_cleanup_metadata(
slot,
state=event.external_state,
operation_id=event.outbox_operation_id,
)
slot.tentative_hold_event_id = None
else:
event = provider.create_event(
session,
tenant_id=tenant_id,
user_id=user_id,
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
)
request.calendar_event_id = event.id
except CalendarCapabilityError as exc:
_calendar_cleanup_metadata(slot, state="retry_required")
warnings.append(str(exc))
warnings.extend(
_release_tentative_calendar_holds(
session,
tenant_id=tenant_id,
request=request,
user_id=user_id,
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
include_selected=False,
)
)
_record_calendar_cleanup_state(
request,
warnings=warnings,
target_state="handed_off",
)
if warnings or not request.calendar_event_id:
previous_handoff = (request.metadata_ or {}).get("calendar_handoff")
previous_handoff = (
previous_handoff if isinstance(previous_handoff, dict) else {}
)
except CalendarCapabilityError as exc:
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id},
"calendar_handoff": {
"status": "retry_required",
"last_known_external_state": (
event.external_state
if event
else previous_handoff.get("last_known_external_state", "retry_required")
),
"outbox_operation_id": (
event.outbox_operation_id
if event
else previous_handoff.get("outbox_operation_id")
),
"event_id": request.calendar_event_id,
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
session.flush()
return request, None, [str(exc)]
request.calendar_event_id = event.id
return request, request.calendar_event_id, warnings
request.handed_off_at = _now()
request.status = "handed_off"
previous_handoff = (request.metadata_ or {}).get("calendar_handoff")
previous_handoff = previous_handoff if isinstance(previous_handoff, dict) else {}
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "accepted",
# Scheduling stores identifiers plus an explicitly named snapshot;
# consumers must resolve the Calendar event/outbox for live state.
"last_known_external_state": event.external_state,
"outbox_operation_id": event.outbox_operation_id,
"event_id": event.id,
"last_known_external_state": (
event.external_state
if event
else previous_handoff.get("last_known_external_state", "accepted")
),
"outbox_operation_id": (
event.outbox_operation_id
if event
else previous_handoff.get("outbox_operation_id")
),
"event_id": request.calendar_event_id,
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request, event.id, []
return request, request.calendar_event_id, []
def create_scheduling_notification_jobs(
@@ -3917,6 +4081,7 @@ def decide_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_slots=True,
)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
@@ -3927,6 +4092,44 @@ def decide_scheduling_request(
"A different slot cannot be selected after a scheduling decision; "
"explicit rescheduling semantics are required"
)
handoff_state = (request.metadata_ or {}).get("calendar_handoff")
cleanup_state = (request.metadata_ or {}).get("calendar_cleanup")
if (
request.status == "decided"
and isinstance(handoff_state, dict)
and handoff_state.get("status") == "retry_required"
):
if not allow_calendar_handoff:
raise SchedulingPermissionError(
"Calendar event promotion or release requires calendar:event:write"
)
return create_final_calendar_event(
session,
tenant_id=tenant_id,
user_id=user_id,
request_id=request.id,
)[0]
if (
request.status == "decided"
and isinstance(cleanup_state, dict)
and cleanup_state.get("status") == "retry_required"
):
if not allow_calendar_handoff:
raise SchedulingPermissionError(
"Calendar event release requires calendar:event:write"
)
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="decided",
)
session.flush()
return request
if request.status != "closed":
raise SchedulingError("Only closed scheduling requests can be decided")
@@ -3939,9 +4142,12 @@ def decide_scheduling_request(
and request.calendar_integration_enabled
and request.create_calendar_event_on_decision
)
if creates_calendar_event and not allow_calendar_handoff:
has_calendar_holds = any(
slot.tentative_hold_event_id for slot in _active_slots(request)
)
if (creates_calendar_event or has_calendar_holds) and not allow_calendar_handoff:
raise SchedulingPermissionError(
"Calendar event creation requires calendar:event:write"
"Calendar event promotion or release requires calendar:event:write"
)
try:
_poll_provider().decide_poll(
@@ -3958,6 +4164,28 @@ def decide_scheduling_request(
if request.calendar_integration_enabled and request.create_calendar_event_on_decision:
create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id)
else:
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="handed_off",
)
if cleanup_warnings:
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "retry_required",
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
session.flush()
return request
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
@@ -3969,17 +4197,37 @@ def decide_scheduling_request(
"calendar_id": request.calendar_id,
},
}
elif has_calendar_holds:
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="decided",
)
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="decision")
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
def cancel_scheduling_request(
session: Session,
*,
tenant_id: str,
request_id: str,
user_id: str | None = None,
allow_calendar_cleanup: bool = False,
) -> SchedulingRequest:
request = _lock_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_slots=True,
)
if request.status == "cancelled":
return request
@@ -3987,6 +4235,27 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
raise SchedulingError(
"Only draft, collecting, or closed scheduling requests can be cancelled"
)
has_calendar_holds = any(
slot.tentative_hold_event_id for slot in _active_slots(request)
)
if has_calendar_holds and not allow_calendar_cleanup:
raise SchedulingPermissionError(
"Calendar event release requires calendar:event:write"
)
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="cancelled",
)
if cleanup_warnings:
session.flush()
return request
cancelled_at = _now()
request.status = "cancelled"
request.cancelled_at = cancelled_at