Skip to content

Commit 9541543

Browse files
Kowserclaude
andcommitted
Move schedule client/models/errors to conductor/client/ai (shimmed)
Schedule/ScheduleInfo dataclasses, the 4 schedule errors, and the scheduler bridge (now AgentScheduleClient; ScheduleClient stays as an alias of the same class) move under conductor/client/ai. OrkesClients gains get_agent_schedule_client() (lazy import); the runtime and the standalone AgentClient schedule surfaces build through it. Old conductor.ai.agents.schedule.* modules re-export the same objects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 13a9897 commit 9541543

9 files changed

Lines changed: 448 additions & 385 deletions

File tree

src/conductor/ai/agents/runtime/http_client.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,11 +364,8 @@ def schedules(self) -> Any:
364364
if self._runtime is not None:
365365
return self._runtime.schedules_client()
366366
if self._schedule_client_instance is None:
367-
from conductor.ai.agents.schedule.client import ScheduleClient
368-
369-
self._schedule_client_instance = ScheduleClient(
370-
self._get_orkes_clients().get_scheduler_client(),
371-
self._workflow_client,
367+
self._schedule_client_instance = (
368+
self._get_orkes_clients().get_agent_schedule_client()
372369
)
373370
return self._schedule_client_instance
374371

src/conductor/ai/agents/runtime/runtime.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,12 +2413,7 @@ def schedules_client(self) -> Any:
24132413
client expose the *same* schedule surface (one instance, not two).
24142414
"""
24152415
if self._schedule_client_instance is None:
2416-
from conductor.ai.agents.schedule.client import ScheduleClient
2417-
2418-
self._schedule_client_instance = ScheduleClient(
2419-
self._clients.get_scheduler_client(),
2420-
self._workflow_client,
2421-
)
2416+
self._schedule_client_instance = self._clients.get_agent_schedule_client()
24222417
return self._schedule_client_instance
24232418

24242419
def _deploy_via_server(self, agent: Any, *, framework: Optional[str] = None) -> str:
Lines changed: 13 additions & 276 deletions
Original file line numberDiff line numberDiff line change
@@ -1,285 +1,22 @@
11
# Copyright (c) 2026 Agentspan
22
# Licensed under the MIT License. See LICENSE file in the project root for details.
33

4-
"""Bridge between :class:`Schedule` and Conductor's ``SchedulerClient``.
4+
"""Backward-compat shim — the schedule client moved to ``conductor.client.ai``.
55
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.
1110
"""
1211

1312
from __future__ import annotations
1413

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,
2822
)
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)
Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
# Copyright (c) 2026 Agentspan
22
# Licensed under the MIT License. See LICENSE file in the project root for details.
33

4-
"""Schedule-specific exceptions."""
5-
6-
from __future__ import annotations
7-
8-
from conductor.ai.agents.exceptions import AgentspanError
9-
10-
11-
class ScheduleError(AgentspanError):
12-
"""Base class for schedule errors."""
4+
"""Backward-compat shim — schedule exceptions moved to ``conductor.client.ai``.
135
6+
Import from :mod:`conductor.client.ai.schedule_errors` (or ``conductor.client.ai``)
7+
going forward. Same class objects, so ``except`` clauses are unaffected.
8+
"""
149

15-
class ScheduleNameConflict(ScheduleError):
16-
"""Two schedules in the same agent share a name."""
17-
18-
19-
class ScheduleNotFound(ScheduleError):
20-
"""No schedule matches the given name."""
21-
10+
from __future__ import annotations
2211

23-
class InvalidCronExpression(ScheduleError):
24-
"""Server rejected the cron expression as malformed."""
12+
from conductor.client.ai.schedule_errors import ( # noqa: F401
13+
InvalidCronExpression,
14+
ScheduleError,
15+
ScheduleNameConflict,
16+
ScheduleNotFound,
17+
)

0 commit comments

Comments
 (0)