|
1 | 1 | # Copyright (c) 2026 Agentspan |
2 | 2 | # Licensed under the MIT License. See LICENSE file in the project root for details. |
3 | 3 |
|
4 | | -"""Bridge between :class:`Schedule` and Conductor's ``SchedulerClient``. |
| 4 | +"""Backward-compat shim — the schedule client moved to ``conductor.client.ai``. |
5 | 5 |
|
6 | | -Handles: |
7 | | -- payload mapping (Schedule -> SaveScheduleRequest, WorkflowSchedule -> ScheduleInfo) |
8 | | -- wire-name prefixing (``{agent}-{short_name}``) |
9 | | -- declarative reconciliation on deploy |
10 | | -- typed error translation |
| 6 | +Import :class:`AgentScheduleClient` from :mod:`conductor.client.ai` (or build one |
| 7 | +via ``OrkesClients.get_agent_schedule_client()``) going forward. ``ScheduleClient`` |
| 8 | +is the same class object, so existing constructions and ``isinstance`` checks are |
| 9 | +unaffected. |
11 | 10 | """ |
12 | 11 |
|
13 | 12 | from __future__ import annotations |
14 | 13 |
|
15 | | -import logging |
16 | | -from typing import Any, Iterable, List, Optional |
17 | | - |
18 | | -from conductor.ai.agents.schedule.errors import ( |
19 | | - InvalidCronExpression, |
20 | | - ScheduleNameConflict, |
21 | | - ScheduleNotFound, |
22 | | -) |
23 | | -from conductor.ai.agents.schedule.schedule import ( |
24 | | - Schedule, |
25 | | - ScheduleInfo, |
26 | | - _prefix, |
27 | | - _unprefix, |
| 14 | +from conductor.client.ai.schedule_client import ( # noqa: F401 |
| 15 | + AgentScheduleClient, |
| 16 | + ScheduleClient, |
| 17 | + _check_unique_names, |
| 18 | + _from_workflow_schedule, |
| 19 | + _read, |
| 20 | + _to_save_request, |
| 21 | + _translate, |
28 | 22 | ) |
29 | | - |
30 | | -logger = logging.getLogger("conductor.ai.agents.schedule") |
31 | | - |
32 | | - |
33 | | -def _to_save_request(schedule: Schedule, agent_name: str) -> Any: |
34 | | - """Build a conductor-python ``SaveScheduleRequest`` from a Schedule.""" |
35 | | - from conductor.client.http.models.save_schedule_request import SaveScheduleRequest |
36 | | - from conductor.client.http.models.start_workflow_request import StartWorkflowRequest |
37 | | - |
38 | | - swr = StartWorkflowRequest( |
39 | | - name=agent_name, |
40 | | - input=dict(schedule.input) if schedule.input else {}, |
41 | | - ) |
42 | | - return SaveScheduleRequest( |
43 | | - name=_prefix(agent_name, schedule.name), |
44 | | - cron_expression=schedule.cron, |
45 | | - zone_id=schedule.timezone, |
46 | | - run_catchup_schedule_instances=schedule.catchup, |
47 | | - paused=schedule.paused, |
48 | | - schedule_start_time=schedule.start_at, |
49 | | - schedule_end_time=schedule.end_at, |
50 | | - description=schedule.description, |
51 | | - start_workflow_request=swr, |
52 | | - ) |
53 | | - |
54 | | - |
55 | | -_DICT_KEY_MAP = { |
56 | | - "name": ("name",), |
57 | | - "cron_expression": ("cron_expression", "cronExpression"), |
58 | | - "zone_id": ("zone_id", "zoneId"), |
59 | | - "paused": ("paused",), |
60 | | - "paused_reason": ("paused_reason", "pausedReason"), |
61 | | - "run_catchup_schedule_instances": ( |
62 | | - "run_catchup_schedule_instances", |
63 | | - "runCatchupScheduleInstances", |
64 | | - ), |
65 | | - "schedule_start_time": ("schedule_start_time", "scheduleStartTime"), |
66 | | - "schedule_end_time": ("schedule_end_time", "scheduleEndTime"), |
67 | | - "description": ("description",), |
68 | | - "next_run_time": ("next_run_time", "nextRunTime"), |
69 | | - "create_time": ("create_time", "createTime"), |
70 | | - "updated_time": ("updated_time", "updatedTime"), |
71 | | - "created_by": ("created_by", "createdBy"), |
72 | | - "updated_by": ("updated_by", "updatedBy"), |
73 | | - "start_workflow_request": ("start_workflow_request", "startWorkflowRequest"), |
74 | | -} |
75 | | - |
76 | | - |
77 | | -def _read(ws: Any, key: str) -> Any: |
78 | | - """Read a field from either a WorkflowSchedule object (snake_case attrs) |
79 | | - or a raw camelCase dict — conductor-python returns both shapes depending |
80 | | - on the endpoint (see ``get_schedule`` vs ``get_all_schedules``).""" |
81 | | - aliases = _DICT_KEY_MAP.get(key, (key,)) |
82 | | - if isinstance(ws, dict): |
83 | | - for k in aliases: |
84 | | - if k in ws: |
85 | | - return ws[k] |
86 | | - return None |
87 | | - for k in aliases: |
88 | | - if hasattr(ws, k): |
89 | | - return getattr(ws, k) |
90 | | - return None |
91 | | - |
92 | | - |
93 | | -def _from_workflow_schedule(ws: Any, agent_name: Optional[str] = None) -> ScheduleInfo: |
94 | | - """Convert conductor-python ``WorkflowSchedule`` (or raw dict) -> ``ScheduleInfo``. |
95 | | -
|
96 | | - Handles both shapes that conductor-python returns: |
97 | | - - ``get_all_schedules`` → ``WorkflowSchedule`` objects with snake_case attrs |
98 | | - - ``get_schedule`` → raw dict with camelCase keys (response_type='object') |
99 | | -
|
100 | | - ``agent_name`` is optional; if omitted, it's derived from |
101 | | - ``startWorkflowRequest.name``. |
102 | | - """ |
103 | | - swr = _read(ws, "start_workflow_request") or {} |
104 | | - wire_name = _read(ws, "name") or "" |
105 | | - swr_name = swr.get("name") if isinstance(swr, dict) else getattr(swr, "name", "") |
106 | | - swr_input = swr.get("input") if isinstance(swr, dict) else getattr(swr, "input", None) |
107 | | - agent = agent_name or swr_name or "" |
108 | | - |
109 | | - return ScheduleInfo( |
110 | | - name=wire_name, |
111 | | - short_name=_unprefix(agent, wire_name), |
112 | | - agent=swr_name or "", |
113 | | - cron=_read(ws, "cron_expression") or "", |
114 | | - timezone=_read(ws, "zone_id") or "UTC", |
115 | | - input=dict(swr_input) if swr_input else {}, |
116 | | - paused=bool(_read(ws, "paused")), |
117 | | - paused_reason=_read(ws, "paused_reason"), |
118 | | - catchup=bool(_read(ws, "run_catchup_schedule_instances")), |
119 | | - start_at=_read(ws, "schedule_start_time"), |
120 | | - end_at=_read(ws, "schedule_end_time"), |
121 | | - description=_read(ws, "description"), |
122 | | - next_run=_read(ws, "next_run_time"), |
123 | | - create_time=_read(ws, "create_time"), |
124 | | - update_time=_read(ws, "updated_time"), |
125 | | - created_by=_read(ws, "created_by"), |
126 | | - updated_by=_read(ws, "updated_by"), |
127 | | - ) |
128 | | - |
129 | | - |
130 | | -def _check_unique_names(schedules: Iterable[Schedule]) -> None: |
131 | | - seen: set = set() |
132 | | - for s in schedules: |
133 | | - if s.name in seen: |
134 | | - raise ScheduleNameConflict( |
135 | | - f"Duplicate schedule name '{s.name}' — names must be unique per agent" |
136 | | - ) |
137 | | - seen.add(s.name) |
138 | | - |
139 | | - |
140 | | -def _translate(exc: Exception) -> Exception: |
141 | | - """Best-effort: map conductor-python HTTP errors to typed schedule errors.""" |
142 | | - status = getattr(exc, "status", None) or getattr(exc, "code", None) |
143 | | - body = getattr(exc, "body", "") or str(exc) |
144 | | - if status == 404: |
145 | | - return ScheduleNotFound(body) |
146 | | - if status == 400 and "cron" in body.lower(): |
147 | | - return InvalidCronExpression(body) |
148 | | - return exc |
149 | | - |
150 | | - |
151 | | -class ScheduleClient: |
152 | | - """Thin wrapper around Conductor's ``OrkesSchedulerClient``. |
153 | | -
|
154 | | - Pause and resume bypass the conductor-python client and issue raw PUT |
155 | | - requests, because the bundled scheduler API spec is out of date — |
156 | | - conductor-python sends ``GET /schedules/{name}/pause`` while modern |
157 | | - Conductor servers (see ``SchedulerResource.java``) expect PUT. |
158 | | - """ |
159 | | - |
160 | | - def __init__(self, scheduler_client: Any, workflow_client: Any) -> None: |
161 | | - self._sc = scheduler_client |
162 | | - self._wc = workflow_client |
163 | | - |
164 | | - def _base_url(self) -> str: |
165 | | - """Resolve the Conductor API base URL from the underlying client config.""" |
166 | | - # OrkesSchedulerClient -> OrkesBaseClient -> api_client.configuration.host |
167 | | - # host is typically "http://localhost:8089/api" |
168 | | - cfg = getattr(self._sc, "configuration", None) or getattr( |
169 | | - getattr(self._sc, "api_client", None), "configuration", None |
170 | | - ) |
171 | | - if cfg is None: |
172 | | - raise RuntimeError("Cannot resolve Conductor base URL from scheduler client") |
173 | | - host = getattr(cfg, "host", None) or getattr(cfg, "base_url", None) |
174 | | - if not host: |
175 | | - raise RuntimeError("Conductor configuration has no host/base_url") |
176 | | - return host.rstrip("/") |
177 | | - |
178 | | - def _http_put(self, path: str, params: Optional[dict] = None) -> None: |
179 | | - import requests as _req |
180 | | - |
181 | | - url = f"{self._base_url()}{path}" |
182 | | - r = _req.put(url, params=params or {}, timeout=15) |
183 | | - if not 200 <= r.status_code < 300: |
184 | | - exc: Any = RuntimeError(f"PUT {url} -> {r.status_code}: {r.text}") |
185 | | - exc.status = r.status_code |
186 | | - exc.body = r.text |
187 | | - raise _translate(exc) |
188 | | - |
189 | | - # ── individual operations (wire-name keyed) ────────────────────── |
190 | | - |
191 | | - def save(self, schedule: Schedule, agent_name: str) -> None: |
192 | | - try: |
193 | | - self._sc.save_schedule(_to_save_request(schedule, agent_name)) |
194 | | - except Exception as exc: # noqa: BLE001 |
195 | | - raise _translate(exc) from exc |
196 | | - |
197 | | - def get(self, wire_name: str, agent_name: Optional[str] = None) -> ScheduleInfo: |
198 | | - try: |
199 | | - ws = self._sc.get_schedule(wire_name) |
200 | | - except Exception as exc: # noqa: BLE001 |
201 | | - raise _translate(exc) from exc |
202 | | - if isinstance(ws, tuple): |
203 | | - ws = ws[0] if ws else None |
204 | | - if not ws: |
205 | | - raise ScheduleNotFound(f"Schedule '{wire_name}' not found") |
206 | | - # Conductor returns 200 + empty/null body for missing schedules; the |
207 | | - # client deserializes that to {} or an empty model. Distinguish that |
208 | | - # from a real schedule by checking for the required ``name`` field. |
209 | | - if not _read(ws, "name"): |
210 | | - raise ScheduleNotFound(f"Schedule '{wire_name}' not found") |
211 | | - return _from_workflow_schedule(ws, agent_name) |
212 | | - |
213 | | - def list_for_agent(self, agent_name: str) -> List[ScheduleInfo]: |
214 | | - try: |
215 | | - results = self._sc.get_all_schedules(workflow_name=agent_name) or [] |
216 | | - except Exception as exc: # noqa: BLE001 |
217 | | - raise _translate(exc) from exc |
218 | | - return [_from_workflow_schedule(ws, agent_name) for ws in results] |
219 | | - |
220 | | - def pause(self, wire_name: str, reason: Optional[str] = None) -> None: |
221 | | - # See class docstring: raw PUT bypasses conductor-python's stale GET verb. |
222 | | - self._http_put( |
223 | | - f"/scheduler/schedules/{wire_name}/pause", |
224 | | - params={"reason": reason} if reason else None, |
225 | | - ) |
226 | | - |
227 | | - def resume(self, wire_name: str) -> None: |
228 | | - self._http_put(f"/scheduler/schedules/{wire_name}/resume") |
229 | | - |
230 | | - def delete(self, wire_name: str) -> None: |
231 | | - try: |
232 | | - self._sc.delete_schedule(wire_name) |
233 | | - except Exception as exc: # noqa: BLE001 |
234 | | - raise _translate(exc) from exc |
235 | | - |
236 | | - def run_now(self, info: ScheduleInfo) -> str: |
237 | | - """Fire the agent once with this schedule's stored input. Returns execution id.""" |
238 | | - from conductor.client.http.models.start_workflow_request import StartWorkflowRequest |
239 | | - |
240 | | - req = StartWorkflowRequest(name=info.agent, input=dict(info.input)) |
241 | | - try: |
242 | | - return self._wc.start_workflow(req) |
243 | | - except Exception as exc: # noqa: BLE001 |
244 | | - raise _translate(exc) from exc |
245 | | - |
246 | | - def preview_next( |
247 | | - self, cron: str, n: int = 5, start_at: Optional[int] = None, end_at: Optional[int] = None |
248 | | - ) -> List[int]: |
249 | | - try: |
250 | | - times = self._sc.get_next_few_schedule_execution_times( |
251 | | - cron_expression=cron, |
252 | | - schedule_start_time=start_at, |
253 | | - schedule_end_time=end_at, |
254 | | - limit=n, |
255 | | - ) |
256 | | - return list(times) if times else [] |
257 | | - except Exception as exc: # noqa: BLE001 |
258 | | - raise _translate(exc) from exc |
259 | | - |
260 | | - # ── declarative reconciliation ────────────────────────────────── |
261 | | - |
262 | | - def reconcile(self, agent_name: str, desired: Optional[List[Schedule]]) -> None: |
263 | | - """Apply the tri-state semantics from spec §5.1: |
264 | | -
|
265 | | - - ``desired is None``: no-op |
266 | | - - ``desired == []``: delete every schedule whose workflow == agent_name |
267 | | - - ``desired == [...]``: upsert listed, delete others scoped to this agent |
268 | | - """ |
269 | | - if desired is None: |
270 | | - return |
271 | | - _check_unique_names(desired) |
272 | | - existing = self.list_for_agent(agent_name) |
273 | | - existing_wire_by_short = {info.short_name: info.name for info in existing} |
274 | | - desired_short = {s.name for s in desired} |
275 | | - |
276 | | - for short, wire in existing_wire_by_short.items(): |
277 | | - if short not in desired_short: |
278 | | - logger.info("Pruning schedule %s for agent %s", wire, agent_name) |
279 | | - self.delete(wire) |
280 | | - |
281 | | - for s in desired: |
282 | | - logger.info( |
283 | | - "Upserting schedule %s for agent %s", _prefix(agent_name, s.name), agent_name |
284 | | - ) |
285 | | - self.save(s, agent_name) |
0 commit comments