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.
- booking_agent/__init__.py +6 -0
- booking_agent/authorization.py +450 -0
- booking_agent/backend/__init__.py +217 -0
- booking_agent/backend/live.py +494 -0
- booking_agent/calendar/__init__.py +55 -0
- booking_agent/calendar/filter.py +41 -0
- booking_agent/calendar/google.py +582 -0
- booking_agent/calendar/oauth_listener.py +287 -0
- booking_agent/calendar/scope.py +97 -0
- booking_agent/circuit_breaker.py +116 -0
- booking_agent/config.py +51 -0
- booking_agent/defaults.env +50 -0
- booking_agent/discovery.py +188 -0
- booking_agent/headless.py +463 -0
- booking_agent/http_deadlines.py +43 -0
- booking_agent/http_retry.py +178 -0
- booking_agent/http_trace.py +428 -0
- booking_agent/merchant_pacing.py +73 -0
- booking_agent/models/__init__.py +14 -0
- booking_agent/models/platform.py +200 -0
- booking_agent/models/wire.py +868 -0
- booking_agent/namespace.py +53 -0
- booking_agent/observability.py +143 -0
- booking_agent/platform_profile.py +120 -0
- booking_agent/pop.py +347 -0
- booking_agent/profile_cache.py +130 -0
- booking_agent/registry_url.py +35 -0
- booking_agent/rest_client.py +1493 -0
- booking_agent/secure_file.py +52 -0
- booking_agent/settings.py +110 -0
- booking_agent/signals.py +130 -0
- booking_agent/step_timing.py +168 -0
- booking_agent/ucp_capabilities.py +148 -0
- booking_agent/ucp_checkout.py +1363 -0
- booking_agent/ucp_negotiation.py +184 -0
- booking_agent/ucp_profile.py +410 -0
- booking_agent/usp_methods.py +110 -0
- booking_agent_cli/__init__.py +3 -0
- booking_agent_cli/activity_log.py +106 -0
- booking_agent_cli/booking_poll.py +141 -0
- booking_agent_cli/booking_summary.py +151 -0
- booking_agent_cli/calendar_connection.py +196 -0
- booking_agent_cli/calendar_event.py +611 -0
- booking_agent_cli/calendar_preference.py +176 -0
- booking_agent_cli/catalog.py +864 -0
- booking_agent_cli/checkout_prereq.py +67 -0
- booking_agent_cli/commands/__init__.py +0 -0
- booking_agent_cli/commands/config.py +106 -0
- booking_agent_cli/commands/flow.py +6988 -0
- booking_agent_cli/commands/lowlevel.py +163 -0
- booking_agent_cli/commands/runtime.py +46 -0
- booking_agent_cli/config_loader.py +108 -0
- booking_agent_cli/engine.py +168 -0
- booking_agent_cli/flow_timings.py +34 -0
- booking_agent_cli/geo_eligibility.py +183 -0
- booking_agent_cli/geocode.py +233 -0
- booking_agent_cli/identity.py +104 -0
- booking_agent_cli/link_cli.py +620 -0
- booking_agent_cli/main.py +75 -0
- booking_agent_cli/output.py +199 -0
- booking_agent_cli/payment_helpers.py +884 -0
- booking_agent_cli/payment_token.py +23 -0
- booking_agent_cli/reschedule.py +137 -0
- booking_agent_cli/runtime.py +55 -0
- booking_agent_cli/runtime_prepare.py +129 -0
- booking_agent_cli/search_area.py +111 -0
- booking_agent_cli/session.py +837 -0
- booking_agent_cli/support_bundle_schema.py +557 -0
- booking_agent_cli/support_report.py +1210 -0
- booking_agent_cli/timezone_context.py +314 -0
- booking_agent_cli-0.1.0.dist-info/METADATA +75 -0
- booking_agent_cli-0.1.0.dist-info/RECORD +74 -0
- booking_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- booking_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Low-level operator commands.
|
|
2
|
+
|
|
3
|
+
These bypass the flow engine and do NOT read or mutate ``.usp-session.json``.
|
|
4
|
+
Documented as repair / debug tools only.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .. import config_loader
|
|
16
|
+
from ..runtime import resolve_backend
|
|
17
|
+
|
|
18
|
+
# search ---------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
search_app = typer.Typer(help="Low-level: search the USP registry (no session writes).")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@search_app.callback(invoke_without_command=True)
|
|
24
|
+
def search(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
query: str = typer.Option(..., "--query", "-q"),
|
|
27
|
+
location: Optional[str] = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--location",
|
|
30
|
+
help="Optional geo filter as latitude,longitude",
|
|
31
|
+
),
|
|
32
|
+
radius_km: Optional[float] = typer.Option(
|
|
33
|
+
None,
|
|
34
|
+
"--radius-km",
|
|
35
|
+
help="Search radius in km when --location is set",
|
|
36
|
+
),
|
|
37
|
+
vertical: Optional[List[str]] = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--vertical",
|
|
40
|
+
help="Repeatable registry vertical filter; omit to search all verticals",
|
|
41
|
+
),
|
|
42
|
+
desired_at: Optional[List[str]] = typer.Option(None, "--desired-at"),
|
|
43
|
+
desired_from: Optional[List[str]] = typer.Option(None, "--desired-from"),
|
|
44
|
+
desired_range: Optional[List[str]] = typer.Option(None, "--desired-range"),
|
|
45
|
+
prefer_sooner_availability_slots: Optional[bool] = typer.Option(
|
|
46
|
+
None,
|
|
47
|
+
"--prefer-sooner-availability-slots/--no-prefer-sooner-availability-slots",
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
if ctx.invoked_subcommand is not None:
|
|
51
|
+
return
|
|
52
|
+
config_loader.load()
|
|
53
|
+
backend = resolve_backend()
|
|
54
|
+
|
|
55
|
+
async def _go():
|
|
56
|
+
from booking_agent.headless import (
|
|
57
|
+
build_search_intent,
|
|
58
|
+
build_desired_service_time_preferences,
|
|
59
|
+
discover_services,
|
|
60
|
+
parse_service_search_location,
|
|
61
|
+
parse_service_verticals,
|
|
62
|
+
)
|
|
63
|
+
from booking_agent.models.platform import ServiceSearchLocation
|
|
64
|
+
|
|
65
|
+
loc = None
|
|
66
|
+
if location and location.strip():
|
|
67
|
+
parsed = parse_service_search_location(location)
|
|
68
|
+
if radius_km is not None:
|
|
69
|
+
loc = ServiceSearchLocation(
|
|
70
|
+
coordinates=parsed.coordinates,
|
|
71
|
+
radius_km=radius_km,
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
loc = parsed
|
|
75
|
+
time_preferences = build_desired_service_time_preferences(
|
|
76
|
+
desired_at=desired_at,
|
|
77
|
+
desired_from=desired_from,
|
|
78
|
+
desired_range=desired_range,
|
|
79
|
+
)
|
|
80
|
+
return await discover_services(
|
|
81
|
+
backend,
|
|
82
|
+
build_search_intent(
|
|
83
|
+
query=query,
|
|
84
|
+
location=loc,
|
|
85
|
+
verticals=parse_service_verticals(vertical),
|
|
86
|
+
desired_service_time_ranges=time_preferences or None,
|
|
87
|
+
prefer_sooner_availability_slots=prefer_sooner_availability_slots,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
services = asyncio.run(_go())
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
typer.echo(json.dumps({"error": str(exc)}), err=True)
|
|
95
|
+
raise typer.Exit(code=1) from exc
|
|
96
|
+
from ..catalog import discover_summary_from_service, sort_discover_summaries_by_rank
|
|
97
|
+
|
|
98
|
+
summaries = []
|
|
99
|
+
for rank, service in enumerate(services):
|
|
100
|
+
row = discover_summary_from_service(service)
|
|
101
|
+
if row.get("search_rank") is None:
|
|
102
|
+
row["search_rank"] = rank
|
|
103
|
+
summaries.append(row)
|
|
104
|
+
typer.echo(json.dumps(sort_discover_summaries_by_rank(summaries), indent=2))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# profile --------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
profile_app = typer.Typer(help="Low-level: profile resolution.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@profile_app.command("resolve")
|
|
113
|
+
def profile_resolve(
|
|
114
|
+
profile_url: str = typer.Option(..., "--profile-url"),
|
|
115
|
+
) -> None:
|
|
116
|
+
config_loader.load()
|
|
117
|
+
backend = resolve_backend()
|
|
118
|
+
|
|
119
|
+
async def _go():
|
|
120
|
+
return await backend.resolve_profile(profile_url)
|
|
121
|
+
|
|
122
|
+
profile = asyncio.run(_go())
|
|
123
|
+
typer.echo(json.dumps({
|
|
124
|
+
"version": profile.version,
|
|
125
|
+
"rest_endpoint": profile.rest_endpoint,
|
|
126
|
+
"business": profile.business.model_dump(by_alias=True),
|
|
127
|
+
"has_catalog": profile.has_catalog,
|
|
128
|
+
"has_availability": profile.has_availability,
|
|
129
|
+
"has_bookings": profile.has_bookings,
|
|
130
|
+
}, indent=2))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# availability ---------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
availability_app = typer.Typer(help="Low-level: query availability.")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@availability_app.command("query")
|
|
139
|
+
def availability_query(
|
|
140
|
+
profile_url: str = typer.Option(..., "--profile-url"),
|
|
141
|
+
service_id: str = typer.Option(..., "--service-id"),
|
|
142
|
+
start: Optional[str] = typer.Option(None, "--from"),
|
|
143
|
+
end: Optional[str] = typer.Option(None, "--to"),
|
|
144
|
+
) -> None:
|
|
145
|
+
config_loader.load()
|
|
146
|
+
now = datetime.now(timezone.utc)
|
|
147
|
+
window_from = start or (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
148
|
+
window_to = end or (now + timedelta(days=14)).replace(hour=23, minute=59, second=59, microsecond=0).isoformat()
|
|
149
|
+
backend = resolve_backend()
|
|
150
|
+
|
|
151
|
+
async def _go():
|
|
152
|
+
from booking_agent.headless import query_availability_with_quirk
|
|
153
|
+
profile = await backend.resolve_profile(profile_url)
|
|
154
|
+
slots = await query_availability_with_quirk(
|
|
155
|
+
backend, profile, service_id, window_from, window_to
|
|
156
|
+
)
|
|
157
|
+
return slots
|
|
158
|
+
|
|
159
|
+
slots = asyncio.run(_go())
|
|
160
|
+
typer.echo(json.dumps(
|
|
161
|
+
[{"slot_id": s.id, "start": s.start, "end": s.end, "duration": s.duration} for s in slots],
|
|
162
|
+
indent=2,
|
|
163
|
+
))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""``booking-agent runtime``: prepare the folders this runtime keeps its state in."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..output import AgentOutput, emit
|
|
9
|
+
from ..runtime_prepare import PrepareError, prepare
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Runtime setup for agents whose filesystem resets between conversations.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("prepare")
|
|
15
|
+
def runtime_prepare(
|
|
16
|
+
persistent_root: str = typer.Option(
|
|
17
|
+
...,
|
|
18
|
+
"--persistent-root",
|
|
19
|
+
help="Absolute folder that survives between conversations in this runtime",
|
|
20
|
+
),
|
|
21
|
+
link: List[str] = typer.Option(
|
|
22
|
+
[],
|
|
23
|
+
"--link",
|
|
24
|
+
help="Extra RUNTIME_PATH=PERSISTENT_PATH folder to keep (relative PERSISTENT_PATH is under the root)",
|
|
25
|
+
),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Link Booking Agent state and the Link sign-in into a folder that outlives this runtime."""
|
|
28
|
+
try:
|
|
29
|
+
links = prepare(persistent_root, link)
|
|
30
|
+
except (PrepareError, OSError) as exc:
|
|
31
|
+
emit(AgentOutput(
|
|
32
|
+
phase="INIT",
|
|
33
|
+
prompt=(
|
|
34
|
+
"Persistence was not set up. Continue the booking without it; state from "
|
|
35
|
+
"this conversation may not survive a restart."
|
|
36
|
+
),
|
|
37
|
+
next_command="booking-agent flow sessions list",
|
|
38
|
+
data={"error": {"code": "runtime_prepare_failed", "message": str(exc)}},
|
|
39
|
+
))
|
|
40
|
+
raise typer.Exit(code=1)
|
|
41
|
+
emit(AgentOutput(
|
|
42
|
+
phase="INIT",
|
|
43
|
+
prompt="Booking Agent state and the Link sign-in now live in the persistent folder.",
|
|
44
|
+
next_command="booking-agent flow sessions list",
|
|
45
|
+
data={"links": links},
|
|
46
|
+
))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Load env files into the process for one command, in priority order.
|
|
2
|
+
|
|
3
|
+
Highest first: the process environment, ``~/.booking-agent/.env``, ``./.env``. Each
|
|
4
|
+
layer only fills names a higher layer left unset. The shipped ``defaults.env`` is the
|
|
5
|
+
lowest layer and is read through ``booking_agent.settings`` instead of being copied
|
|
6
|
+
into the environment.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from dotenv import dotenv_values
|
|
16
|
+
|
|
17
|
+
from booking_agent import settings
|
|
18
|
+
|
|
19
|
+
SOURCE_PROCESS = "process environment"
|
|
20
|
+
SOURCE_USER = "user .env"
|
|
21
|
+
SOURCE_FOLDER = "folder .env"
|
|
22
|
+
SOURCE_SHIPPED = "shipped default"
|
|
23
|
+
|
|
24
|
+
# Names this module put into os.environ, with the value and the layer they came from.
|
|
25
|
+
_applied: Dict[str, Tuple[str, str]] = {}
|
|
26
|
+
_loaded_files: List[Tuple[str, Path]] = []
|
|
27
|
+
_ignored_runtime: List[Tuple[str, Path, str]] = []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Layer:
|
|
32
|
+
source: str
|
|
33
|
+
path: Path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def user_env_path() -> Path:
|
|
37
|
+
return settings.booking_agent_home() / ".env"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def layers() -> List[Layer]:
|
|
41
|
+
return [
|
|
42
|
+
Layer(SOURCE_USER, user_env_path()),
|
|
43
|
+
Layer(SOURCE_FOLDER, Path.cwd() / ".env"),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def reset() -> None:
|
|
48
|
+
"""Remove every value this module put into the environment."""
|
|
49
|
+
for name, (value, _source) in _applied.items():
|
|
50
|
+
if os.environ.get(name) == value:
|
|
51
|
+
del os.environ[name]
|
|
52
|
+
_applied.clear()
|
|
53
|
+
_loaded_files.clear()
|
|
54
|
+
_ignored_runtime.clear()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load() -> None:
|
|
58
|
+
"""Re-apply the layers for this command. Safe to call more than once per process."""
|
|
59
|
+
reset()
|
|
60
|
+
for layer in layers():
|
|
61
|
+
if not layer.path.is_file():
|
|
62
|
+
continue
|
|
63
|
+
_loaded_files.append((layer.source, layer.path))
|
|
64
|
+
for name, value in dotenv_values(layer.path).items():
|
|
65
|
+
if name in settings.RUNTIME_ONLY:
|
|
66
|
+
_ignored_runtime.append((layer.source, layer.path, name))
|
|
67
|
+
continue
|
|
68
|
+
if value is None or name in os.environ:
|
|
69
|
+
continue
|
|
70
|
+
os.environ[name] = value
|
|
71
|
+
_applied[name] = (value, layer.source)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def source_of(name: str) -> str:
|
|
75
|
+
if name in _applied and os.environ.get(name) == _applied[name][0]:
|
|
76
|
+
return _applied[name][1]
|
|
77
|
+
if name in os.environ:
|
|
78
|
+
return SOURCE_PROCESS
|
|
79
|
+
if settings.shipped_default(name) is None:
|
|
80
|
+
return "unset"
|
|
81
|
+
return SOURCE_SHIPPED
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def process_value(name: str) -> Optional[str]:
|
|
85
|
+
"""The value the process started with; a value an env file supplied is never returned."""
|
|
86
|
+
if name in _applied and os.environ.get(name) == _applied[name][0]:
|
|
87
|
+
return None
|
|
88
|
+
return os.environ.get(name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def loaded_files() -> List[Tuple[str, Path]]:
|
|
92
|
+
return list(_loaded_files)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unknown_keys() -> List[Tuple[str, Path, str]]:
|
|
96
|
+
"""Names in the loaded env files that nothing reads (for example renamed variables)."""
|
|
97
|
+
known = settings.known_names()
|
|
98
|
+
out = []
|
|
99
|
+
for source, path in _loaded_files:
|
|
100
|
+
for name in dotenv_values(path):
|
|
101
|
+
if name not in known:
|
|
102
|
+
out.append((source, path, name))
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ignored_runtime_keys() -> List[Tuple[str, Path, str]]:
|
|
107
|
+
"""Per-process names found in an env file. They are never read from one."""
|
|
108
|
+
return list(_ignored_runtime)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Flow state machine (Section 6 of PLAN).
|
|
2
|
+
|
|
3
|
+
The engine reads + writes ``.usp-session.json`` via session.py. Gate
|
|
4
|
+
helpers raise ``GateError`` so command handlers can emit a uniform
|
|
5
|
+
agent-facing JSON error and exit 1.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .session import BUYER_SOURCE_LINKCLI, Buyer, Session
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GateError(Exception):
|
|
15
|
+
"""Raised when a hard gate has not been satisfied."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, code: str, message: str, *, next_command: str | None = None):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.code = code
|
|
20
|
+
self.message = message
|
|
21
|
+
self.next_command = next_command
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TransitionRule:
|
|
26
|
+
"""Allowed phase progression check (advisory; used by validate)."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def require_phase(session: Session, *allowed: str, code: str, next_command: str | None = None) -> None:
|
|
32
|
+
if session.flow_phase not in allowed:
|
|
33
|
+
raise GateError(
|
|
34
|
+
code,
|
|
35
|
+
f"Wrong phase '{session.flow_phase}'. Expected one of: {', '.join(allowed)}.",
|
|
36
|
+
next_command=next_command,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_business(session: Session) -> None:
|
|
41
|
+
if not session.business.profile_url:
|
|
42
|
+
raise GateError(
|
|
43
|
+
"no_business",
|
|
44
|
+
"No business selected. Run `booking-agent flow business use --profile-url ...` "
|
|
45
|
+
"or `booking-agent flow discover`.",
|
|
46
|
+
next_command="booking-agent flow discover",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def require_calendar_gate(session: Session) -> None:
|
|
51
|
+
if not session.calendar_gate_completed:
|
|
52
|
+
raise GateError(
|
|
53
|
+
"calendar_gate_not_completed",
|
|
54
|
+
"Calendar gate not completed. Ask the user about calendar conflict "
|
|
55
|
+
"checking, then run `booking-agent flow calendar ask|skip|connect`.",
|
|
56
|
+
next_command="booking-agent flow calendar ask",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def require_link_auth_gate(session: Session) -> None:
|
|
61
|
+
if not session.link_auth_gate_completed:
|
|
62
|
+
raise GateError(
|
|
63
|
+
"link_auth_gate_not_completed",
|
|
64
|
+
"Link wallet authentication not completed. Run "
|
|
65
|
+
"`booking-agent flow link-auth connect`, post the sign-in link, and start "
|
|
66
|
+
"`booking-agent flow link-auth poll` in the background in the same turn.",
|
|
67
|
+
next_command="booking-agent flow link-auth connect",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def require_checkout_prereq(session: Session) -> None:
|
|
72
|
+
if session.checkout_prereq_completed:
|
|
73
|
+
return
|
|
74
|
+
if getattr(session, "payment_route", None) in ("free", "at_service"):
|
|
75
|
+
return
|
|
76
|
+
raise GateError(
|
|
77
|
+
"checkout_prereq_not_completed",
|
|
78
|
+
"Checkout prerequisites were not completed. Run the checkout-mode "
|
|
79
|
+
"orchestrator before the calendar gate.",
|
|
80
|
+
next_command="booking-agent flow use-ucp-checkout-mode",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def require_slot_selected(session: Session) -> None:
|
|
85
|
+
if not session.slot_id or not session.slot_user_confirmed:
|
|
86
|
+
raise GateError(
|
|
87
|
+
"slot_not_selected",
|
|
88
|
+
"No user-confirmed slot. Present `slots_presented` to the user, "
|
|
89
|
+
"wait for their pick, then call `booking-agent flow slots select`.",
|
|
90
|
+
next_command="booking-agent flow slots list",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def require_buyer_from_link_cli(session: Session) -> None:
|
|
95
|
+
if session.buyer_source != BUYER_SOURCE_LINKCLI:
|
|
96
|
+
raise GateError(
|
|
97
|
+
"buyer_not_from_link_cli",
|
|
98
|
+
"Buyer must come from `link-cli user-info retrieve`. Placeholders, "
|
|
99
|
+
"hand-entered values, and other sources are rejected.",
|
|
100
|
+
next_command="booking-agent flow buyer resolve",
|
|
101
|
+
)
|
|
102
|
+
email = session.buyer.email or ""
|
|
103
|
+
if email and not email.lower().endswith("@example.com"):
|
|
104
|
+
return
|
|
105
|
+
from .link_cli import (
|
|
106
|
+
LinkCliUserInfoError,
|
|
107
|
+
buyer_fields_from_user_info,
|
|
108
|
+
retrieve_user_info,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
payload = retrieve_user_info()
|
|
113
|
+
except LinkCliUserInfoError as e:
|
|
114
|
+
raise GateError(
|
|
115
|
+
e.code,
|
|
116
|
+
e.message,
|
|
117
|
+
next_command="booking-agent flow buyer resolve",
|
|
118
|
+
) from e
|
|
119
|
+
name, email, phone = buyer_fields_from_user_info(payload)
|
|
120
|
+
if not email or email.lower().endswith("@example.com"):
|
|
121
|
+
raise GateError(
|
|
122
|
+
"buyer_email_invalid",
|
|
123
|
+
"Buyer email is missing or uses an @example.com placeholder.",
|
|
124
|
+
next_command="booking-agent flow buyer resolve",
|
|
125
|
+
)
|
|
126
|
+
session.buyer = Buyer(name=name, email=email, phone=phone)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def transition(session: Session, *, to: str) -> None:
|
|
130
|
+
"""In-place phase update with the previous phase implicit."""
|
|
131
|
+
session.flow_phase = to
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def recommend_next(session: Session) -> str | None:
|
|
135
|
+
"""Best-effort suggestion for the agent's next command."""
|
|
136
|
+
phase = session.flow_phase
|
|
137
|
+
pending = session.pending_booking_attempt
|
|
138
|
+
if isinstance(pending, dict) and isinstance(pending.get("retry_command"), str):
|
|
139
|
+
return pending["retry_command"]
|
|
140
|
+
if phase == "CONFIRMED" and session.reschedule_original_slot_id is not None:
|
|
141
|
+
if session.reschedule_slot_id:
|
|
142
|
+
return "booking-agent flow reschedule apply --user-confirmed"
|
|
143
|
+
if session.reschedule_slots:
|
|
144
|
+
return "booking-agent flow reschedule select --slot-id <id> --user-confirmed"
|
|
145
|
+
return "booking-agent flow reschedule availability"
|
|
146
|
+
if phase == "BUSINESS_SELECTED":
|
|
147
|
+
return "booking-agent flow use-ucp-checkout-mode"
|
|
148
|
+
if phase == "BOOKED":
|
|
149
|
+
return (
|
|
150
|
+
"booking-agent flow pay request --payment-method-id <id>"
|
|
151
|
+
)
|
|
152
|
+
if phase == "PAYMENT_PENDING":
|
|
153
|
+
sr = session.payment.spend_request_id
|
|
154
|
+
if sr:
|
|
155
|
+
return f"booking-agent flow pay ucp-complete --spend-request-id {sr}"
|
|
156
|
+
return "booking-agent flow pay ucp-complete"
|
|
157
|
+
table = {
|
|
158
|
+
"INIT": "booking-agent flow prereq check",
|
|
159
|
+
"PREREQ_OK": "booking-agent flow discover",
|
|
160
|
+
"CALENDAR_GATE": "booking-agent flow availability",
|
|
161
|
+
"AVAILABILITY_QUERIED": "booking-agent flow slots list",
|
|
162
|
+
"SLOTS_PRESENTED": "booking-agent flow slots select --slot-id <id>",
|
|
163
|
+
"SLOT_SELECTED": "booking-agent flow link-auth connect",
|
|
164
|
+
"BUYER_RESOLVED": "booking-agent flow book",
|
|
165
|
+
"CONFIRMED": None,
|
|
166
|
+
"FAILED": None,
|
|
167
|
+
}
|
|
168
|
+
return table.get(phase)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Persist per-step flow timings on the session and surface them in CLI output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
from booking_agent.step_timing import drain_pending_timings, StepTiming
|
|
7
|
+
|
|
8
|
+
from .session import Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def apply_pending_timings(session: Session) -> None:
|
|
12
|
+
"""Merge drained step timings into the session record."""
|
|
13
|
+
for timing in drain_pending_timings():
|
|
14
|
+
if timing.step.startswith("human_gate:"):
|
|
15
|
+
gate = timing.step.split(":", 1)[1]
|
|
16
|
+
session.human_gate_timings[gate] = round(timing.human_gate_ms, 1)
|
|
17
|
+
else:
|
|
18
|
+
session.flow_timings[timing.step] = timing.to_dict()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def timings_data(session: Session) -> Dict[str, Any]:
|
|
22
|
+
"""Payload fragment for AgentOutput ``data``."""
|
|
23
|
+
return {
|
|
24
|
+
"flow_timings": dict(session.flow_timings),
|
|
25
|
+
"human_gate_timings": dict(session.human_gate_timings),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record_timing_on_session(session: Session, timing: StepTiming) -> None:
|
|
30
|
+
if timing.step.startswith("human_gate:"):
|
|
31
|
+
gate = timing.step.split(":", 1)[1]
|
|
32
|
+
session.human_gate_timings[gate] = round(timing.human_gate_ms, 1)
|
|
33
|
+
else:
|
|
34
|
+
session.flow_timings[timing.step] = timing.to_dict()
|