booking-agent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. booking_agent/__init__.py +6 -0
  2. booking_agent/authorization.py +450 -0
  3. booking_agent/backend/__init__.py +217 -0
  4. booking_agent/backend/live.py +494 -0
  5. booking_agent/calendar/__init__.py +55 -0
  6. booking_agent/calendar/filter.py +41 -0
  7. booking_agent/calendar/google.py +582 -0
  8. booking_agent/calendar/oauth_listener.py +287 -0
  9. booking_agent/calendar/scope.py +97 -0
  10. booking_agent/circuit_breaker.py +116 -0
  11. booking_agent/config.py +51 -0
  12. booking_agent/defaults.env +50 -0
  13. booking_agent/discovery.py +188 -0
  14. booking_agent/headless.py +463 -0
  15. booking_agent/http_deadlines.py +43 -0
  16. booking_agent/http_retry.py +178 -0
  17. booking_agent/http_trace.py +428 -0
  18. booking_agent/merchant_pacing.py +73 -0
  19. booking_agent/models/__init__.py +14 -0
  20. booking_agent/models/platform.py +200 -0
  21. booking_agent/models/wire.py +868 -0
  22. booking_agent/namespace.py +53 -0
  23. booking_agent/observability.py +143 -0
  24. booking_agent/platform_profile.py +120 -0
  25. booking_agent/pop.py +347 -0
  26. booking_agent/profile_cache.py +130 -0
  27. booking_agent/registry_url.py +35 -0
  28. booking_agent/rest_client.py +1493 -0
  29. booking_agent/secure_file.py +52 -0
  30. booking_agent/settings.py +110 -0
  31. booking_agent/signals.py +130 -0
  32. booking_agent/step_timing.py +168 -0
  33. booking_agent/ucp_capabilities.py +148 -0
  34. booking_agent/ucp_checkout.py +1363 -0
  35. booking_agent/ucp_negotiation.py +184 -0
  36. booking_agent/ucp_profile.py +410 -0
  37. booking_agent/usp_methods.py +110 -0
  38. booking_agent_cli/__init__.py +3 -0
  39. booking_agent_cli/activity_log.py +106 -0
  40. booking_agent_cli/booking_poll.py +141 -0
  41. booking_agent_cli/booking_summary.py +151 -0
  42. booking_agent_cli/calendar_connection.py +196 -0
  43. booking_agent_cli/calendar_event.py +611 -0
  44. booking_agent_cli/calendar_preference.py +176 -0
  45. booking_agent_cli/catalog.py +864 -0
  46. booking_agent_cli/checkout_prereq.py +67 -0
  47. booking_agent_cli/commands/__init__.py +0 -0
  48. booking_agent_cli/commands/config.py +106 -0
  49. booking_agent_cli/commands/flow.py +6988 -0
  50. booking_agent_cli/commands/lowlevel.py +163 -0
  51. booking_agent_cli/commands/runtime.py +46 -0
  52. booking_agent_cli/config_loader.py +108 -0
  53. booking_agent_cli/engine.py +168 -0
  54. booking_agent_cli/flow_timings.py +34 -0
  55. booking_agent_cli/geo_eligibility.py +183 -0
  56. booking_agent_cli/geocode.py +233 -0
  57. booking_agent_cli/identity.py +104 -0
  58. booking_agent_cli/link_cli.py +620 -0
  59. booking_agent_cli/main.py +75 -0
  60. booking_agent_cli/output.py +199 -0
  61. booking_agent_cli/payment_helpers.py +884 -0
  62. booking_agent_cli/payment_token.py +23 -0
  63. booking_agent_cli/reschedule.py +137 -0
  64. booking_agent_cli/runtime.py +55 -0
  65. booking_agent_cli/runtime_prepare.py +129 -0
  66. booking_agent_cli/search_area.py +111 -0
  67. booking_agent_cli/session.py +837 -0
  68. booking_agent_cli/support_bundle_schema.py +557 -0
  69. booking_agent_cli/support_report.py +1210 -0
  70. booking_agent_cli/timezone_context.py +314 -0
  71. booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
  72. booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
  73. booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  74. booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,837 @@
1
+ """``.usp-session.json`` schema, persistence, and read helpers.
2
+
3
+ The *store* is the single source of truth for the flow engine. Only
4
+ ``session.py`` writes the file. The engine reads via these accessors and
5
+ calls ``save_session`` / ``save_store`` to persist transitions.
6
+
7
+ File format (schema_version 2):
8
+ {
9
+ "schema_version": 2,
10
+ "max_sessions": 30,
11
+ "sessions_created": <int, never decrements>,
12
+ "active_session_id": "<uuid>",
13
+ "sessions": [ { "id": "...", "created_at": "...", "user_request": "...", ... } ]
14
+ }
15
+
16
+ Migration: a file whose top-level JSON has no "sessions" key is the old
17
+ schema-version-1 single-object format and is transparently upgraded.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import uuid
24
+ from dataclasses import dataclass, field, fields, asdict
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
28
+
29
+ from booking_agent import settings
30
+ from booking_agent.secure_file import ensure_private_dir, write_private_text
31
+
32
+ if TYPE_CHECKING:
33
+ from booking_agent.authorization import BookingScopedCredential
34
+
35
+ SCHEMA_VERSION = 2 # per-Session object format version
36
+ STORE_SCHEMA_VERSION = 2 # SessionStore envelope version
37
+ SESSION_FILE_NAME = ".usp-session.json"
38
+ DEFAULT_MAX_SESSIONS = 30
39
+
40
+ VALID_PHASES = (
41
+ "INIT",
42
+ "PREREQ_OK",
43
+ "BUSINESS_SELECTED",
44
+ "CALENDAR_GATE",
45
+ "AVAILABILITY_QUERIED",
46
+ "SLOTS_PRESENTED",
47
+ "SLOT_SELECTED",
48
+ "BUYER_RESOLVED",
49
+ "BOOKED",
50
+ "PAYMENT_PENDING",
51
+ "CONFIRMED",
52
+ "FAILED",
53
+ )
54
+
55
+ TERMINAL_PHASES = frozenset({"CONFIRMED", "FAILED"})
56
+
57
+ # Phases where a selected future slot may still need cancel authority or resume.
58
+ _ACTIONABLE_IN_PROGRESS_PHASES = frozenset({
59
+ "SLOT_SELECTED",
60
+ "BUYER_RESOLVED",
61
+ "BOOKED",
62
+ "PAYMENT_PENDING",
63
+ "CONFIRMED",
64
+ })
65
+
66
+ # Booking statuses where the appointment can no longer be canceled or moved.
67
+ # ``confirmed`` is absent on purpose: polling is done, but the buyer can still
68
+ # cancel or reschedule a confirmed booking.
69
+ INACTIVE_BOOKING_STATUSES = frozenset({"completed", "canceled", "no_show"})
70
+
71
+ BUYER_SOURCE_LINKCLI = "link-cli user-info retrieve"
72
+
73
+
74
+ @dataclass
75
+ class Business:
76
+ profile_url: Optional[str] = None
77
+ rest_endpoint: Optional[str] = None
78
+ name: Optional[str] = None
79
+ timezone: Optional[str] = None # IANA zone of the selected business; None until a source names one
80
+
81
+
82
+ @dataclass
83
+ class AvailabilityWindow:
84
+ from_: Optional[str] = None # ISO 8601
85
+ to: Optional[str] = None # ISO 8601
86
+
87
+
88
+ @dataclass
89
+ class SlotRef:
90
+ slot_id: str
91
+ start: str
92
+ end: str
93
+ duration: Optional[str] = None
94
+ price_amount: Optional[int] = None
95
+ # Other resources' slot ids for the same start and end, tried in order if this one is taken.
96
+ alternate_slot_ids: List[str] = field(default_factory=list)
97
+
98
+
99
+ @dataclass
100
+ class Buyer:
101
+ name: str = ""
102
+ email: str = ""
103
+ phone: Optional[str] = None
104
+
105
+
106
+ @dataclass
107
+ class BookingRef:
108
+ id: Optional[str] = None
109
+ checkout_path: Optional[str] = None # ucp | none
110
+ payment_context: Optional[Dict[str, Any]] = None
111
+ action_expires_at: Optional[str] = None # ISO 8601; when the checkout expires
112
+
113
+ # -- USP Section 10.1.6 per-resource authorization ------------------------
114
+ # The booking-scoped credential issued at creation, keyed implicitly by
115
+ # (endpoint, id): both are recorded so the credential is never presented to a
116
+ # business other than the one that issued it.
117
+ endpoint: Optional[str] = None # issuing business REST endpoint
118
+ credential: Optional[str] = None # bsc_… value
119
+ credential_expires_at: Optional[str] = None # ISO 8601
120
+ credential_jkt: Optional[str] = None # cnf.jkt the credential is bound to
121
+ credential_scope: Optional[List[str]] = None # USP methods it authorizes, if narrowed
122
+ revision: Optional[str] = None # for If-Match conditional writes (Section 5.6)
123
+ booking_url: Optional[str] = None # human handoff target on 401/expiry
124
+ status: Optional[str] = None # last booking status read from or set by the business
125
+
126
+
127
+ @dataclass
128
+ class PaymentRef:
129
+ status: Optional[str] = None # pending | paid | failed
130
+ spend_request_id: Optional[str] = None
131
+ order_id: Optional[str] = None # UCP complete_checkout order correlation (step 20)
132
+ spend_approval_surfaced: bool = False # True after pay request emitted the approval URL once
133
+ spend_approval_user_surfaced: bool = False # True after agent acked posting URL to the user
134
+ approved_at: Optional[str] = None # ISO 8601 when Link approval was observed (convenience copy)
135
+ spend_action_surfaced: Optional[str] = None # SpendNextAction.key already shown to the user
136
+ payment_method_id: Optional[str] = None # funding source of the last spend request, for a replacement
137
+ # Last spend request a complete_checkout declined; spent, never offered again.
138
+ declined_spend_request_id: Optional[str] = None
139
+ declined_code: Optional[str] = None # UCP message code of that decline
140
+ declined_checkout_id: Optional[str] = None
141
+ # complete_checkout captured the money but the booking is not confirmed; no
142
+ # further charge may run until the booking is resolved.
143
+ captured_unconfirmed: bool = False
144
+
145
+
146
+ @dataclass
147
+ class LastError:
148
+ code: str
149
+ message: str
150
+
151
+
152
+ @dataclass
153
+ class ActivityEntry:
154
+ at: str
155
+ kind: str # command | error
156
+ text: str
157
+ code: Optional[str] = None
158
+
159
+
160
+ @dataclass
161
+ class Session:
162
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
163
+ correlation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
164
+ created_at: str = field(
165
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
166
+ )
167
+ user_request: str = ""
168
+ schema_version: int = SCHEMA_VERSION
169
+ flow_phase: str = "INIT"
170
+ business: Business = field(default_factory=Business)
171
+ service_timezone: Optional[str] = None # selected registry result's zone; business.timezone wins
172
+ buyer_timezone: Optional[str] = None # IANA zone of the buyer's default Link shipping address
173
+ buyer_timezone_source: Optional[str] = None
174
+ buyer_timezone_error: Optional[str] = None # sanitized code from the last failed lookup
175
+ timezone_difference_shown: bool = False # user picked a slot from a dual-zone list
176
+ slots_presented_dual_zone: bool = False # last slots list carried buyer-zone displays
177
+ calendar_gate_completed: bool = False
178
+ calendar_state: str = "unset" # connected | skipped | unset
179
+ calendar_mode: str = "unset" # none | freebusy | events | unset
180
+ calendar_route: Optional[str] = None # connector | local-oauth
181
+ calendar_token_env: Optional[str] = None # name of the host variable, never its value
182
+ link_auth_gate_completed: bool = False
183
+ availability_window: AvailabilityWindow = field(default_factory=AvailabilityWindow)
184
+ slots_presented: List[SlotRef] = field(default_factory=list)
185
+ slot_id: Optional[str] = None
186
+ slot_user_confirmed: bool = False
187
+ service_id: Optional[str] = None
188
+ selection_mismatch: Optional[str] = None # less-direct label the user confirmed at business use
189
+ timezone_conflict_confirmed: bool = False # user accepted a service-location/timezone conflict
190
+ live_service: Optional[Dict[str, Any]] = None # GET /services/{id} snapshot before availability
191
+ payment_timing: Optional[str] = None # at_booking | at_service | deposit_required | None
192
+ payment_route: Optional[str] = None # free | at_service | at_booking | deposit_required
193
+ selected_term_id: Optional[str] = None
194
+ payment_schedules: Optional[List[Dict[str, Any]]] = None
195
+ due_now_amount_minor: Optional[int] = None
196
+ payment_disclosures: Optional[List[Dict[str, Any]]] = None
197
+ disclosures_user_confirmed: bool = False
198
+ total_confirmed_amount_minor: Optional[int] = None # checkout total the user re-approved
199
+ accepted_term: Optional[Dict[str, Any]] = None
200
+ complete_checkout_idempotency_key: Optional[str] = None
201
+ # Direct POST /bookings the business refused as invalid_profile_url:
202
+ # {idempotency_key, fingerprint, retry_command}. Reused only for the same booking.
203
+ pending_booking_attempt: Optional[Dict[str, Any]] = None
204
+ checkout_mode: Optional[str] = None # ucp_native | direct; set by business use
205
+ supported_checkout_modes: List[str] = field(default_factory=list)
206
+ checkout_prereq_completed: bool = False
207
+ ucp_checkout_rest_base: Optional[str] = None
208
+ ucp_checkout_session_id: Optional[str] = None
209
+ ucp_checkout_amount_minor: Optional[int] = None # totals.total from create_checkout (minor units)
210
+ ucp_merchant_quote: Optional[Dict[str, Any]] = None # merchant checkout totals (Track A)
211
+ ucp_checkout_links: Optional[List[Dict[str, Any]]] = None
212
+ ucp_checkout_policies: Optional[List[Dict[str, Any]]] = None
213
+ ucp_checkout_messages: Optional[List[Dict[str, Any]]] = None
214
+ ucp_spt_network_id: Optional[str] = None # from checkout payment_handlers config at book
215
+ # Negotiated UCP capability set. Authoritative source is the checkout response
216
+ # echo (checkout.ucp.capabilities/.version), NOT the platform-side profile
217
+ # intersection - see ucp_capabilities.py. Persisted so complete_checkout can
218
+ # reuse it without re-fetching the profile.
219
+ ucp_protocol_version: Optional[str] = None # authoritative negotiated UCP version (response echo)
220
+ ucp_negotiated_capabilities: Dict[str, List[str]] = field(default_factory=dict) # {namespace: [versions]}
221
+ ucp_capabilities_authoritative: bool = False # True once confirmed by the business response echo
222
+ ucp_checkout_cancel_attempted: bool = False
223
+ ucp_checkout_cancel_succeeded: Optional[bool] = None
224
+ ucp_checkout_cancel_error: Optional[str] = None # short, non-secret cancel failure summary
225
+ buyer_source: str = "none" # link-cli user-info retrieve | none
226
+ buyer: Buyer = field(default_factory=Buyer)
227
+ booking: BookingRef = field(default_factory=BookingRef)
228
+ payment: PaymentRef = field(default_factory=PaymentRef)
229
+ last_error: Optional[LastError] = None
230
+ activity_log: List[ActivityEntry] = field(default_factory=list)
231
+ discover_query: Optional[str] = None # last registry search query (support reports read it)
232
+ discover_location_lat: Optional[float] = None
233
+ discover_location_lng: Optional[float] = None
234
+ discover_location_radius_km: Optional[float] = None
235
+ discover_registry_url: Optional[str] = None # resolved POST URL for registry search
236
+ discover_registry_request_body: Optional[Dict[str, Any]] = None # raw search_services JSON
237
+ discover_services: List[Dict[str, Any]] = field(default_factory=list)
238
+ flow_timings: Dict[str, Dict[str, float]] = field(default_factory=dict)
239
+ human_gate_timings: Dict[str, float] = field(default_factory=dict)
240
+ skill_compliance_validated: bool = False
241
+ # Reschedule working state for a CONFIRMED booking. slot_id and slots_presented
242
+ # keep describing the booked slot until the business accepts the move.
243
+ reschedule_original_slot_id: Optional[str] = None # set by reschedule start
244
+ reschedule_slots: List[SlotRef] = field(default_factory=list)
245
+ reschedule_slot_id: Optional[str] = None
246
+ reschedule_user_confirmed: bool = False
247
+ # Consented support report (spec #307). The bytes are frozen at offer time and
248
+ # are the only thing `flow support-report send` may deliver.
249
+ support_report_declined: bool = False
250
+ support_report_asked: bool = False
251
+ support_report_bytes: Optional[str] = None
252
+ support_report_recovery_command: Optional[str] = None
253
+ support_report_send_failures: int = 0
254
+ # Area the user confirmed for a place-based search:
255
+ # {label, lat, lng, source, link_address_id, street_level, confirmed}.
256
+ search_area: Optional[Dict[str, Any]] = None
257
+ # Geocoded proposal shown to the user and not yet confirmed; ``purpose`` is
258
+ # ``search`` or ``service_address``.
259
+ pending_area: Optional[Dict[str, Any]] = None
260
+ # Confirmed street address (USP DeliveryAddress fields) for home visits.
261
+ service_address: Optional[Dict[str, Any]] = None
262
+ service_address_label: Optional[str] = None
263
+ area_resume_command: Optional[str] = None # command to rerun after an address confirmation
264
+ booking_notes: Optional[str] = None # note for the business, set at buyer resolve
265
+ calendar_timezone: Optional[str] = None # primary Google Calendar zone (events mode only)
266
+
267
+
268
+ @dataclass
269
+ class SessionStore:
270
+ schema_version: int = STORE_SCHEMA_VERSION
271
+ max_sessions: int = DEFAULT_MAX_SESSIONS
272
+ sessions_created: int = 0 # lifetime counter; never decrements on reset
273
+ active_session_id: Optional[str] = None
274
+ last_calendar_state: Optional[str] = None # connected | skipped
275
+ last_calendar_mode: Optional[str] = None # freebusy | events | none
276
+ last_calendar_route: Optional[str] = None # connector | local-oauth
277
+ last_calendar_token_env: Optional[str] = None # name of the host variable, never its value
278
+ sessions: List[Session] = field(default_factory=list)
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Path resolution
283
+ # ---------------------------------------------------------------------------
284
+
285
+ def session_path() -> Path:
286
+ """Return the path to the session store file.
287
+
288
+ Override with ``BOOKING_AGENT_SESSION_DIR`` env var (used in tests to isolate
289
+ each test's store from the real ``~/.booking-agent`` directory).
290
+ """
291
+ base = settings.booking_agent_home()
292
+ ensure_private_dir(base)
293
+ return base / SESSION_FILE_NAME
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Store-level I/O
298
+ # ---------------------------------------------------------------------------
299
+
300
+ def load_store() -> SessionStore:
301
+ p = session_path()
302
+ if not p.exists():
303
+ max_s = settings.get_int("BOOKING_AGENT_MAX_SESSIONS")
304
+ return SessionStore(max_sessions=max_s)
305
+ raw = json.loads(p.read_text())
306
+ if "sessions" not in raw:
307
+ store = _migrate_v1(raw)
308
+ else:
309
+ store = _store_from_dict(raw)
310
+ if maintain_store(store):
311
+ save_store(store)
312
+ return store
313
+
314
+
315
+ def save_store(store: SessionStore) -> Path:
316
+ # The store now carries booking-scoped credentials, so it is written 0600.
317
+ p = session_path()
318
+ write_private_text(p, json.dumps(_store_to_dict(store), indent=2, sort_keys=False))
319
+ return p
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Session-level helpers (thin wrappers that the flow commands use)
324
+ # ---------------------------------------------------------------------------
325
+
326
+ def bind_session_observability(session: Optional[Session]) -> None:
327
+ """Bind the flow correlation id for outbound HTTP and stderr events."""
328
+ from booking_agent.observability import bind, reset
329
+
330
+ if session and session.correlation_id:
331
+ bind(session.correlation_id)
332
+ else:
333
+ reset()
334
+
335
+
336
+ def load_session() -> Optional[Session]:
337
+ session = get_active_session(load_store())
338
+ bind_session_observability(session)
339
+ return session
340
+
341
+
342
+ SUPPORT_REPORT_FIELDS = (
343
+ "support_report_declined",
344
+ "support_report_asked",
345
+ "support_report_bytes",
346
+ "support_report_recovery_command",
347
+ "support_report_send_failures",
348
+ )
349
+
350
+
351
+ def save_session(session: Session, *, write_support_report: bool = False) -> Path:
352
+ """Persist an updated session back into the store.
353
+
354
+ Commands often save an in-memory session loaded before ``emit_error`` froze a
355
+ support report, so only callers that own the report may change those fields.
356
+ """
357
+ store = load_store()
358
+ for i, s in enumerate(store.sessions):
359
+ if s.id == session.id:
360
+ if not write_support_report:
361
+ for name in SUPPORT_REPORT_FIELDS:
362
+ setattr(session, name, getattr(s, name))
363
+ store.sessions[i] = session
364
+ return save_store(store)
365
+ # Defensive: session not found in store — append it
366
+ store.sessions.append(session)
367
+ store.active_session_id = session.id
368
+ return save_store(store)
369
+
370
+
371
+ def delete_session() -> bool:
372
+ """Remove the active session entry from the store. Returns True if removed."""
373
+ store = load_store()
374
+ active = get_active_session(store)
375
+ if active is None:
376
+ return False
377
+ store.sessions = [s for s in store.sessions if s.id != active.id]
378
+ store.active_session_id = store.sessions[-1].id if store.sessions else None
379
+ save_store(store)
380
+ return True
381
+
382
+
383
+ def must_load() -> Session:
384
+ s = load_session()
385
+ if s is None:
386
+ raise FileNotFoundError(
387
+ "No active session. Run `booking-agent flow init` first."
388
+ )
389
+ return s
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Store mutation helpers
394
+ # ---------------------------------------------------------------------------
395
+
396
+ def get_active_session(store: SessionStore) -> Optional[Session]:
397
+ if not store.sessions:
398
+ return None
399
+ if store.active_session_id:
400
+ for s in store.sessions:
401
+ if s.id == store.active_session_id:
402
+ return s
403
+ return store.sessions[-1]
404
+
405
+
406
+ def set_active_session(store: SessionStore, session_id: str) -> bool:
407
+ """Point the store's active pointer at ``session_id``. Returns False if not found."""
408
+ for s in store.sessions:
409
+ if s.id == session_id:
410
+ store.active_session_id = session_id
411
+ return True
412
+ return False
413
+
414
+
415
+ def add_session(store: SessionStore, session: Session) -> None:
416
+ """Append a new session, set it as active, drop oldest non-actionable if over limit."""
417
+ store.sessions.append(session)
418
+ store.sessions_created += 1
419
+ store.active_session_id = session.id
420
+ maintain_store(store)
421
+
422
+
423
+ def session_slot_end(session: Session, *, now: Optional[datetime] = None) -> Optional[datetime]:
424
+ """Return the UTC end time of the selected slot, if known."""
425
+ _ = now
426
+ if not session.slot_id:
427
+ return None
428
+ for slot in session.slots_presented:
429
+ if slot.slot_id != session.slot_id:
430
+ continue
431
+ try:
432
+ end = datetime.fromisoformat(slot.end.replace("Z", "+00:00"))
433
+ except (ValueError, AttributeError):
434
+ return None
435
+ if end.tzinfo is None:
436
+ end = end.replace(tzinfo=timezone.utc)
437
+ return end
438
+ return None
439
+
440
+
441
+ def is_actionable(session: Session, *, now: Optional[datetime] = None) -> bool:
442
+ """True while the consumer may still need cancel/resume for this booking."""
443
+ if session.flow_phase == "FAILED":
444
+ return False
445
+ if session.booking.status in INACTIVE_BOOKING_STATUSES:
446
+ return False
447
+ now = now or datetime.now(timezone.utc)
448
+ slot_end = session_slot_end(session)
449
+
450
+ if session.booking.id:
451
+ if slot_end is None:
452
+ return True
453
+ return slot_end >= now
454
+
455
+ if session.flow_phase in _ACTIONABLE_IN_PROGRESS_PHASES and session.slot_id:
456
+ if slot_end is None:
457
+ return True
458
+ return slot_end >= now
459
+
460
+ return False
461
+
462
+
463
+ def archive_expired_session(session: Session, *, now: Optional[datetime] = None) -> bool:
464
+ """Strip secrets from a session whose appointment has passed. Returns True if modified."""
465
+ now = now or datetime.now(timezone.utc)
466
+ slot_end = session_slot_end(session)
467
+ if slot_end is None or slot_end >= now:
468
+ return False
469
+ clear_credential(session)
470
+ session.buyer = Buyer()
471
+ session.buyer_source = "none"
472
+ session.search_area = None
473
+ session.pending_area = None
474
+ session.service_address = None
475
+ session.service_address_label = None
476
+ session.booking_notes = None
477
+ # Keep dispute join keys (#189): spend request id, order id, payment status, approval time.
478
+ # Wipe only ephemeral approval-gate flags and anything secret-bearing.
479
+ session.payment.spend_approval_surfaced = False
480
+ session.payment.spend_approval_user_surfaced = False
481
+ session.payment.spend_action_surfaced = None
482
+ return True
483
+
484
+
485
+ def maintain_store(store: SessionStore, *, now: Optional[datetime] = None) -> bool:
486
+ """Archive past appointments and trim only non-actionable sessions."""
487
+ now = now or datetime.now(timezone.utc)
488
+ modified = False
489
+ for session in store.sessions:
490
+ if archive_expired_session(session, now=now):
491
+ modified = True
492
+ before_len = len(store.sessions)
493
+ before_active = store.active_session_id
494
+ _trim_non_actionable_sessions(store, now=now)
495
+ if len(store.sessions) != before_len or store.active_session_id != before_active:
496
+ modified = True
497
+ return modified
498
+
499
+
500
+ def _trim_non_actionable_sessions(store: SessionStore, *, now: Optional[datetime] = None) -> None:
501
+ now = now or datetime.now(timezone.utc)
502
+ while len(store.sessions) > store.max_sessions:
503
+ droppable = [
504
+ (idx, sess)
505
+ for idx, sess in enumerate(store.sessions)
506
+ if not is_actionable(sess, now=now)
507
+ ]
508
+ if not droppable:
509
+ break
510
+ drop_idx = min(droppable, key=lambda item: item[1].created_at)[0]
511
+ dropped = store.sessions.pop(drop_idx)
512
+ if store.active_session_id == dropped.id:
513
+ store.active_session_id = (
514
+ store.sessions[-1].id if store.sessions else None
515
+ )
516
+
517
+
518
+ def forget_session(store: SessionStore, session_id: str) -> bool:
519
+ """Remove one session by id. Does not touch Link/Google identity stores."""
520
+ for idx, session in enumerate(store.sessions):
521
+ if session.id != session_id:
522
+ continue
523
+ store.sessions.pop(idx)
524
+ if store.active_session_id == session_id:
525
+ store.active_session_id = (
526
+ store.sessions[-1].id if store.sessions else None
527
+ )
528
+ return True
529
+ return False
530
+
531
+
532
+ # ---------------------------------------------------------------------------
533
+ # Booking-scoped credentials (USP Section 10.1.6)
534
+ # ---------------------------------------------------------------------------
535
+
536
+ def record_issued_credential(
537
+ session: Session,
538
+ credential: Optional["BookingScopedCredential"],
539
+ *,
540
+ endpoint: Optional[str],
541
+ booking_url: Optional[str] = None,
542
+ revision: Optional[str] = None,
543
+ ) -> None:
544
+ """Persist the credential a business issued at resource creation.
545
+
546
+ The endpoint is stored alongside it so ``credential_for`` can refuse to hand
547
+ it to a different business.
548
+ """
549
+ session.booking.endpoint = endpoint
550
+ if booking_url:
551
+ session.booking.booking_url = booking_url
552
+ if revision:
553
+ session.booking.revision = revision
554
+ if credential is None:
555
+ return
556
+ session.booking.credential = credential.value
557
+ session.booking.credential_expires_at = credential.expires_at.isoformat()
558
+ session.booking.credential_jkt = credential.cnf.jkt if credential.cnf else None
559
+ session.booking.credential_scope = list(credential.scope) if credential.scope else None
560
+
561
+
562
+ def credential_for(
563
+ session: Session, *, endpoint: Optional[str],
564
+ ) -> Optional["BookingScopedCredential"]:
565
+ """Return the stored credential, but only for the business that issued it.
566
+
567
+ A booking-scoped credential is a secret scoped to one resource at one
568
+ business. Presenting it elsewhere would leak it to a party with no claim to
569
+ it, so an endpoint mismatch returns None rather than the credential.
570
+ """
571
+ from booking_agent.authorization import BookingScopedCredential
572
+
573
+ ref = session.booking
574
+ if not ref.credential or not ref.credential_expires_at:
575
+ return None
576
+ if endpoint and ref.endpoint and endpoint != ref.endpoint:
577
+ return None
578
+ payload: Dict[str, Any] = {
579
+ "value": ref.credential,
580
+ "expires_at": ref.credential_expires_at,
581
+ }
582
+ if ref.credential_jkt:
583
+ payload["cnf"] = {"jkt": ref.credential_jkt}
584
+ if ref.credential_scope:
585
+ payload["scope"] = ref.credential_scope
586
+ try:
587
+ return BookingScopedCredential.model_validate(payload)
588
+ except Exception:
589
+ return None
590
+
591
+
592
+ def credential_for_session_resource(
593
+ session: Session,
594
+ *,
595
+ endpoint: Optional[str],
596
+ resolved_business_endpoint: Optional[str] = None,
597
+ ) -> Optional["BookingScopedCredential"]:
598
+ """Return a credential for a related binding of this session's business.
599
+
600
+ Exact issuer matches remain the default. UCP-native checkout is the sole
601
+ cross-binding exception: checkout issues the booking credential, while the
602
+ resulting booking resource is read from the USP REST binding in the same
603
+ business profile.
604
+ """
605
+ exact = credential_for(session, endpoint=endpoint)
606
+ if exact is not None:
607
+ return exact
608
+
609
+ issuer = session.booking.endpoint
610
+ checkout_endpoint = session.ucp_checkout_rest_base
611
+ usp_endpoint = session.business.rest_endpoint or resolved_business_endpoint
612
+ if (
613
+ issuer
614
+ and endpoint
615
+ and checkout_endpoint
616
+ and usp_endpoint
617
+ and issuer == checkout_endpoint
618
+ and endpoint == usp_endpoint
619
+ ):
620
+ return credential_for(session, endpoint=issuer)
621
+ return None
622
+
623
+
624
+ def clear_credential(session: Session) -> None:
625
+ """Drop the credential at a terminal state; it is invalid from that point."""
626
+ ref = session.booking
627
+ ref.credential = None
628
+ ref.credential_expires_at = None
629
+ ref.credential_jkt = None
630
+ ref.credential_scope = None
631
+
632
+
633
+ def endpoint_still_in_use(
634
+ store: SessionStore, endpoint: Optional[str], *, excluding_session_id: Optional[str] = None,
635
+ ) -> bool:
636
+ """True when any other session still holds a live credential at *endpoint*.
637
+
638
+ This is what makes PoP key destruction safe. The key is per-endpoint while
639
+ credentials are per-resource, so dropping the key while another booking's
640
+ credential is still bound to it would leave that booking permanently
641
+ uncancellable -- USP defines no re-issuance path for a key you no longer
642
+ hold.
643
+ """
644
+ if not endpoint:
645
+ return False
646
+ now = datetime.now(timezone.utc)
647
+ for sess in store.sessions:
648
+ if excluding_session_id and sess.id == excluding_session_id:
649
+ continue
650
+ ref = sess.booking
651
+ if ref.endpoint != endpoint or not ref.credential:
652
+ continue
653
+ if not ref.credential_expires_at:
654
+ return True
655
+ try:
656
+ expires = datetime.fromisoformat(ref.credential_expires_at)
657
+ except ValueError:
658
+ return True
659
+ if expires.tzinfo is None:
660
+ expires = expires.replace(tzinfo=timezone.utc)
661
+ if expires > now:
662
+ return True
663
+ return False
664
+
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # Serialisation
668
+ # ---------------------------------------------------------------------------
669
+
670
+ def _store_to_dict(store: SessionStore) -> Dict[str, Any]:
671
+ d: Dict[str, Any] = {
672
+ "schema_version": store.schema_version,
673
+ "max_sessions": store.max_sessions,
674
+ "sessions_created": store.sessions_created,
675
+ "active_session_id": store.active_session_id,
676
+ "sessions": [_session_to_dict(s) for s in store.sessions],
677
+ }
678
+ if store.last_calendar_state is not None:
679
+ d["last_calendar_state"] = store.last_calendar_state
680
+ if store.last_calendar_mode is not None:
681
+ d["last_calendar_mode"] = store.last_calendar_mode
682
+ if store.last_calendar_route is not None:
683
+ d["last_calendar_route"] = store.last_calendar_route
684
+ if store.last_calendar_token_env is not None:
685
+ d["last_calendar_token_env"] = store.last_calendar_token_env
686
+ return d
687
+
688
+
689
+ def _store_from_dict(raw: Dict[str, Any]) -> SessionStore:
690
+ max_s = int(os.environ.get(
691
+ "BOOKING_AGENT_MAX_SESSIONS",
692
+ raw.get("max_sessions") or settings.get_int("BOOKING_AGENT_MAX_SESSIONS"),
693
+ ))
694
+ return SessionStore(
695
+ schema_version=raw.get("schema_version", STORE_SCHEMA_VERSION),
696
+ max_sessions=max_s,
697
+ sessions_created=raw.get("sessions_created", 0),
698
+ active_session_id=raw.get("active_session_id"),
699
+ last_calendar_state=raw.get("last_calendar_state"),
700
+ last_calendar_mode=raw.get("last_calendar_mode"),
701
+ last_calendar_route=raw.get("last_calendar_route"),
702
+ last_calendar_token_env=raw.get("last_calendar_token_env"),
703
+ sessions=[_session_from_dict(s) for s in (raw.get("sessions") or [])],
704
+ )
705
+
706
+
707
+ def _session_to_dict(s: Session) -> Dict[str, Any]:
708
+ d = asdict(s)
709
+ aw = d.pop("availability_window")
710
+ d["availability_window"] = {"from": aw.get("from_"), "to": aw.get("to")}
711
+ buyer = d.get("buyer")
712
+ if isinstance(buyer, dict):
713
+ # Agent half of spec #159: do not persist buyer contact on disk.
714
+ # Name and buyer_source stay; email/phone are rehydrated from link-cli.
715
+ buyer["email"] = ""
716
+ buyer["phone"] = None
717
+ return d
718
+
719
+
720
+ def _known_fields(cls, raw: Dict[str, Any]):
721
+ names = {f.name for f in fields(cls)}
722
+ return cls(**{k: v for k, v in raw.items() if k in names})
723
+
724
+
725
+ def _session_from_dict(raw: Dict[str, Any]) -> Session:
726
+ raw = dict(raw) # avoid mutating caller's dict
727
+ aw_in = raw.get("availability_window") or {}
728
+ raw["availability_window"] = {
729
+ "from_": aw_in.get("from") or aw_in.get("from_"),
730
+ "to": aw_in.get("to"),
731
+ }
732
+ return Session(
733
+ id=raw.get("id") or str(uuid.uuid4()),
734
+ correlation_id=raw.get("correlation_id") or str(uuid.uuid4()),
735
+ created_at=raw.get("created_at") or datetime.now(timezone.utc).isoformat(),
736
+ user_request=raw.get("user_request", ""),
737
+ schema_version=raw.get("schema_version", SCHEMA_VERSION),
738
+ flow_phase=raw.get("flow_phase", "INIT"),
739
+ business=Business(**(raw.get("business") or {})),
740
+ service_timezone=raw.get("service_timezone"),
741
+ buyer_timezone=raw.get("buyer_timezone"),
742
+ buyer_timezone_source=raw.get("buyer_timezone_source"),
743
+ buyer_timezone_error=raw.get("buyer_timezone_error"),
744
+ timezone_difference_shown=bool(raw.get("timezone_difference_shown", False)),
745
+ slots_presented_dual_zone=bool(raw.get("slots_presented_dual_zone", False)),
746
+ calendar_gate_completed=raw.get("calendar_gate_completed", False),
747
+ calendar_state=raw.get("calendar_state", "unset"),
748
+ calendar_mode=raw.get("calendar_mode", "unset"),
749
+ calendar_route=raw.get("calendar_route"),
750
+ calendar_token_env=raw.get("calendar_token_env"),
751
+ link_auth_gate_completed=raw.get("link_auth_gate_completed", False),
752
+ availability_window=AvailabilityWindow(**raw["availability_window"]),
753
+ slots_presented=[SlotRef(**s) for s in (raw.get("slots_presented") or [])],
754
+ slot_id=raw.get("slot_id"),
755
+ slot_user_confirmed=raw.get("slot_user_confirmed", False),
756
+ service_id=raw.get("service_id"),
757
+ selection_mismatch=raw.get("selection_mismatch"),
758
+ timezone_conflict_confirmed=bool(raw.get("timezone_conflict_confirmed", False)),
759
+ live_service=raw.get("live_service"),
760
+ payment_timing=raw.get("payment_timing"),
761
+ payment_route=raw.get("payment_route"),
762
+ selected_term_id=raw.get("selected_term_id"),
763
+ payment_schedules=list(raw.get("payment_schedules") or []) or None,
764
+ due_now_amount_minor=raw.get("due_now_amount_minor"),
765
+ payment_disclosures=list(raw.get("payment_disclosures") or []) or None,
766
+ disclosures_user_confirmed=bool(raw.get("disclosures_user_confirmed", False)),
767
+ total_confirmed_amount_minor=raw.get("total_confirmed_amount_minor"),
768
+ accepted_term=raw.get("accepted_term"),
769
+ complete_checkout_idempotency_key=raw.get("complete_checkout_idempotency_key"),
770
+ pending_booking_attempt=(
771
+ raw["pending_booking_attempt"]
772
+ if isinstance(raw.get("pending_booking_attempt"), dict)
773
+ else None
774
+ ),
775
+ checkout_mode=raw.get("checkout_mode"),
776
+ supported_checkout_modes=list(raw.get("supported_checkout_modes") or []),
777
+ checkout_prereq_completed=raw.get("checkout_prereq_completed", False),
778
+ ucp_checkout_rest_base=raw.get("ucp_checkout_rest_base"),
779
+ ucp_checkout_session_id=raw.get("ucp_checkout_session_id"),
780
+ ucp_checkout_amount_minor=raw.get("ucp_checkout_amount_minor"),
781
+ ucp_merchant_quote=raw.get("ucp_merchant_quote"),
782
+ ucp_checkout_links=list(raw.get("ucp_checkout_links") or []) or None,
783
+ ucp_checkout_policies=list(raw.get("ucp_checkout_policies") or []) or None,
784
+ ucp_checkout_messages=list(raw.get("ucp_checkout_messages") or []) or None,
785
+ ucp_spt_network_id=raw.get("ucp_spt_network_id"),
786
+ ucp_protocol_version=raw.get("ucp_protocol_version"),
787
+ ucp_negotiated_capabilities=dict(raw.get("ucp_negotiated_capabilities") or {}),
788
+ ucp_capabilities_authoritative=raw.get("ucp_capabilities_authoritative", False),
789
+ ucp_checkout_cancel_attempted=bool(raw.get("ucp_checkout_cancel_attempted", False)),
790
+ ucp_checkout_cancel_succeeded=raw.get("ucp_checkout_cancel_succeeded"),
791
+ ucp_checkout_cancel_error=raw.get("ucp_checkout_cancel_error"),
792
+ buyer_source=raw.get("buyer_source", "none"),
793
+ buyer=Buyer(**(raw.get("buyer") or {})),
794
+ booking=_known_fields(BookingRef, raw.get("booking") or {}),
795
+ payment=PaymentRef(**(raw.get("payment") or {})),
796
+ last_error=(LastError(**raw["last_error"]) if raw.get("last_error") else None),
797
+ activity_log=[ActivityEntry(**e) for e in (raw.get("activity_log") or [])],
798
+ discover_query=raw.get("discover_query"),
799
+ discover_location_lat=raw.get("discover_location_lat"),
800
+ discover_location_lng=raw.get("discover_location_lng"),
801
+ discover_location_radius_km=raw.get("discover_location_radius_km"),
802
+ discover_registry_url=raw.get("discover_registry_url"),
803
+ discover_registry_request_body=raw.get("discover_registry_request_body"),
804
+ discover_services=list(raw.get("discover_services") or []),
805
+ flow_timings=dict(raw.get("flow_timings") or {}),
806
+ human_gate_timings=dict(raw.get("human_gate_timings") or {}),
807
+ skill_compliance_validated=raw.get("skill_compliance_validated", False),
808
+ reschedule_original_slot_id=raw.get("reschedule_original_slot_id"),
809
+ reschedule_slots=[SlotRef(**s) for s in (raw.get("reschedule_slots") or [])],
810
+ reschedule_slot_id=raw.get("reschedule_slot_id"),
811
+ reschedule_user_confirmed=bool(raw.get("reschedule_user_confirmed", False)),
812
+ support_report_declined=bool(raw.get("support_report_declined", False)),
813
+ support_report_asked=bool(raw.get("support_report_asked", False)),
814
+ support_report_bytes=raw.get("support_report_bytes"),
815
+ support_report_recovery_command=raw.get("support_report_recovery_command"),
816
+ support_report_send_failures=int(raw.get("support_report_send_failures") or 0),
817
+ search_area=raw.get("search_area"),
818
+ pending_area=raw.get("pending_area"),
819
+ service_address=raw.get("service_address"),
820
+ service_address_label=raw.get("service_address_label"),
821
+ area_resume_command=raw.get("area_resume_command"),
822
+ booking_notes=raw.get("booking_notes"),
823
+ calendar_timezone=raw.get("calendar_timezone"),
824
+ )
825
+
826
+
827
+ def _migrate_v1(raw: Dict[str, Any]) -> SessionStore:
828
+ """Wrap a schema-version-1 single-object file in a v2 store envelope."""
829
+ session = _session_from_dict(raw)
830
+ max_s = settings.get_int("BOOKING_AGENT_MAX_SESSIONS")
831
+ return SessionStore(
832
+ schema_version=STORE_SCHEMA_VERSION,
833
+ max_sessions=max_s,
834
+ sessions_created=1, # the migrated session counts toward the lifetime total
835
+ active_session_id=session.id,
836
+ sessions=[session],
837
+ )