feat: reconcile tentative calendar holds
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user