Skip to content

Commit 2791fd1

Browse files
Kowserclaude
andcommitted
S2: schedule lifecycle as concrete SchedulerClient methods; mapping layer canonical in schedule.py
SchedulerClient's native get_schedule/save_schedule/get_all_schedules are the source of truth for reads/writes/lists — deliberately no domain twins. The six lifecycle operations that add real value beyond the raw endpoints move onto the ABC as concrete methods implemented over the abstract endpoint methods, so any implementation gets them for free: pause(reason=)/resume/delete/preview_next — typed-error translation run_now(info) — via a _start_workflow() hook (OrkesSchedulerClient implements it over WorkflowResourceApi) reconcile(agent, desired) — tri-state declarative diff with {agent}-{short} wire prefixing The Schedule/ScheduleInfo mapping helpers (payload mapping, dual-shape reads, typed-error translation) move from schedule_client.py into conductor/client/ai/schedule.py next to the models they map, plus new _get_info/ _list_infos read helpers for the module-level schedules.* API. schedule_client.py re-imports them so the AgentScheduleClient facade and the conductor.ai.agents.schedule.client re-export chain are byte-compatible. conductor.client.ai imports inside scheduler_client.py are deliberately lazy — the module is on every SDK program's import path and must not pull the agent surface at import time (pinned by a subprocess guard test). New tests/unit/orkes/test_scheduler_domain_surface.py (17 tests): lifecycle delegation, typed errors, reconcile tri-state, _get_info mapping, run_now NotImplementedError default, ai-layer import isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 74d88d9 commit 2791fd1

5 files changed

Lines changed: 582 additions & 123 deletions

File tree

src/conductor/client/ai/schedule.py

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
# Copyright (c) 2026 Agentspan
22
# Licensed under the MIT License. See LICENSE file in the project root for details.
33

4-
"""User-facing dataclasses: ``Schedule`` (input) and ``ScheduleInfo`` (output).
4+
"""User-facing dataclasses: ``Schedule`` (input) and ``ScheduleInfo`` (output),
5+
plus the mapping layer between them and Conductor's ``SchedulerClient`` models.
56
67
``Schedule`` is what users construct and pass to ``deploy(..., schedules=[...])``.
78
``ScheduleInfo`` is what ``schedules.list/get`` returns — it carries the
89
prefixed wire name plus server-computed fields like ``next_run``.
10+
11+
The private helpers below (payload mapping, wire-name prefixing, typed error
12+
translation) are shared by ``SchedulerClient``'s schedule-lifecycle methods,
13+
the module-level ``schedules.*`` API, and the deprecated ``AgentScheduleClient``
14+
shim. ``SchedulerClient``'s native ``get_schedule``/``save_schedule``/
15+
``get_all_schedules`` are the source of truth for reads/writes/lists; the
16+
``ScheduleInfo`` view exists for the module-level API.
917
"""
1018

1119
from __future__ import annotations
1220

1321
from dataclasses import dataclass, field
14-
from typing import Any, Dict, Optional
22+
from typing import Any, Dict, Iterable, List, Optional
23+
24+
from conductor.client.ai.schedule_errors import (
25+
InvalidCronExpression,
26+
ScheduleNameConflict,
27+
ScheduleNotFound,
28+
)
1529

1630

1731
def _prefix(agent_name: str, short_name: str) -> str:
@@ -86,3 +100,149 @@ class ScheduleInfo:
86100
update_time: Optional[int]
87101
created_by: Optional[str]
88102
updated_by: Optional[str]
103+
104+
105+
# ── mapping layer (private) ─────────────────────────────────────────
106+
107+
108+
def _to_save_request(schedule: Schedule, agent_name: str) -> Any:
109+
"""Build a conductor-python ``SaveScheduleRequest`` from a Schedule."""
110+
from conductor.client.http.models.save_schedule_request import SaveScheduleRequest
111+
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
112+
113+
swr = StartWorkflowRequest(
114+
name=agent_name,
115+
input=dict(schedule.input) if schedule.input else {},
116+
)
117+
return SaveScheduleRequest(
118+
name=_prefix(agent_name, schedule.name),
119+
cron_expression=schedule.cron,
120+
zone_id=schedule.timezone,
121+
run_catchup_schedule_instances=schedule.catchup,
122+
paused=schedule.paused,
123+
schedule_start_time=schedule.start_at,
124+
schedule_end_time=schedule.end_at,
125+
description=schedule.description,
126+
start_workflow_request=swr,
127+
)
128+
129+
130+
_DICT_KEY_MAP = {
131+
"name": ("name",),
132+
"cron_expression": ("cron_expression", "cronExpression"),
133+
"zone_id": ("zone_id", "zoneId"),
134+
"paused": ("paused",),
135+
"paused_reason": ("paused_reason", "pausedReason"),
136+
"run_catchup_schedule_instances": (
137+
"run_catchup_schedule_instances",
138+
"runCatchupScheduleInstances",
139+
),
140+
"schedule_start_time": ("schedule_start_time", "scheduleStartTime"),
141+
"schedule_end_time": ("schedule_end_time", "scheduleEndTime"),
142+
"description": ("description",),
143+
"next_run_time": ("next_run_time", "nextRunTime"),
144+
"create_time": ("create_time", "createTime"),
145+
"updated_time": ("updated_time", "updatedTime"),
146+
"created_by": ("created_by", "createdBy"),
147+
"updated_by": ("updated_by", "updatedBy"),
148+
"start_workflow_request": ("start_workflow_request", "startWorkflowRequest"),
149+
}
150+
151+
152+
def _read(ws: Any, key: str) -> Any:
153+
"""Read a field from either a WorkflowSchedule object (snake_case attrs)
154+
or a raw camelCase dict — duck-typed ``SchedulerClient`` implementations
155+
may return either shape."""
156+
aliases = _DICT_KEY_MAP.get(key, (key,))
157+
if isinstance(ws, dict):
158+
for k in aliases:
159+
if k in ws:
160+
return ws[k]
161+
return None
162+
for k in aliases:
163+
if hasattr(ws, k):
164+
return getattr(ws, k)
165+
return None
166+
167+
168+
def _from_workflow_schedule(ws: Any, agent_name: Optional[str] = None) -> ScheduleInfo:
169+
"""Convert conductor-python ``WorkflowSchedule`` (or raw dict) -> ``ScheduleInfo``.
170+
171+
``agent_name`` is optional; if omitted, it's derived from
172+
``startWorkflowRequest.name``.
173+
"""
174+
swr = _read(ws, "start_workflow_request") or {}
175+
wire_name = _read(ws, "name") or ""
176+
swr_name = swr.get("name") if isinstance(swr, dict) else getattr(swr, "name", "")
177+
swr_input = swr.get("input") if isinstance(swr, dict) else getattr(swr, "input", None)
178+
agent = agent_name or swr_name or ""
179+
180+
return ScheduleInfo(
181+
name=wire_name,
182+
short_name=_unprefix(agent, wire_name),
183+
agent=swr_name or "",
184+
cron=_read(ws, "cron_expression") or "",
185+
timezone=_read(ws, "zone_id") or "UTC",
186+
input=dict(swr_input) if swr_input else {},
187+
paused=bool(_read(ws, "paused")),
188+
paused_reason=_read(ws, "paused_reason"),
189+
catchup=bool(_read(ws, "run_catchup_schedule_instances")),
190+
start_at=_read(ws, "schedule_start_time"),
191+
end_at=_read(ws, "schedule_end_time"),
192+
description=_read(ws, "description"),
193+
next_run=_read(ws, "next_run_time"),
194+
create_time=_read(ws, "create_time"),
195+
update_time=_read(ws, "updated_time"),
196+
created_by=_read(ws, "created_by"),
197+
updated_by=_read(ws, "updated_by"),
198+
)
199+
200+
201+
def _check_unique_names(schedules: Iterable[Schedule]) -> None:
202+
seen: set = set()
203+
for s in schedules:
204+
if s.name in seen:
205+
raise ScheduleNameConflict(
206+
f"Duplicate schedule name '{s.name}' — names must be unique per agent"
207+
)
208+
seen.add(s.name)
209+
210+
211+
def _translate(exc: Exception) -> Exception:
212+
"""Best-effort: map conductor-python HTTP errors to typed schedule errors."""
213+
status = getattr(exc, "status", None) or getattr(exc, "code", None)
214+
body = getattr(exc, "body", "") or str(exc)
215+
if status == 404:
216+
return ScheduleNotFound(body)
217+
if status == 400 and "cron" in body.lower():
218+
return InvalidCronExpression(body)
219+
return exc
220+
221+
222+
def _get_info(raw_client: Any, wire_name: str, agent_name: Optional[str] = None) -> ScheduleInfo:
223+
"""Fetch one schedule via a raw ``SchedulerClient`` and map it to ``ScheduleInfo``.
224+
225+
Raises :class:`ScheduleNotFound` for missing schedules — including Conductor's
226+
200-with-empty-body responses, which older/duck-typed clients surface as ``{}``
227+
or an empty model rather than ``None``.
228+
"""
229+
try:
230+
ws = raw_client.get_schedule(wire_name)
231+
except Exception as exc: # noqa: BLE001
232+
raise _translate(exc) from exc
233+
if isinstance(ws, tuple):
234+
ws = ws[0] if ws else None
235+
if not ws:
236+
raise ScheduleNotFound(f"Schedule '{wire_name}' not found")
237+
if not _read(ws, "name"):
238+
raise ScheduleNotFound(f"Schedule '{wire_name}' not found")
239+
return _from_workflow_schedule(ws, agent_name)
240+
241+
242+
def _list_infos(raw_client: Any, agent_name: str) -> List[ScheduleInfo]:
243+
"""List an agent's schedules via a raw ``SchedulerClient`` as ``ScheduleInfo``s."""
244+
try:
245+
results = raw_client.get_all_schedules(workflow_name=agent_name) or []
246+
except Exception as exc: # noqa: BLE001
247+
raise _translate(exc) from exc
248+
return [_from_workflow_schedule(ws, agent_name) for ws in results]

src/conductor/client/ai/schedule_client.py

Lines changed: 8 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,18 @@
1313
from __future__ import annotations
1414

1515
import logging
16-
from typing import Any, Iterable, List, Optional
16+
from typing import Any, List, Optional
1717

18-
from conductor.client.ai.schedule import (
18+
from conductor.client.ai.schedule import ( # noqa: F401 — re-exported for compat
1919
Schedule,
2020
ScheduleInfo,
21+
_DICT_KEY_MAP,
22+
_check_unique_names,
23+
_from_workflow_schedule,
2124
_prefix,
25+
_read,
26+
_to_save_request,
27+
_translate,
2228
_unprefix,
2329
)
2430
from conductor.client.ai.schedule_errors import (
@@ -33,124 +39,6 @@
3339
logger = logging.getLogger("conductor.ai.agents.schedule")
3440

3541

36-
def _to_save_request(schedule: Schedule, agent_name: str) -> Any:
37-
"""Build a conductor-python ``SaveScheduleRequest`` from a Schedule."""
38-
from conductor.client.http.models.save_schedule_request import SaveScheduleRequest
39-
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
40-
41-
swr = StartWorkflowRequest(
42-
name=agent_name,
43-
input=dict(schedule.input) if schedule.input else {},
44-
)
45-
return SaveScheduleRequest(
46-
name=_prefix(agent_name, schedule.name),
47-
cron_expression=schedule.cron,
48-
zone_id=schedule.timezone,
49-
run_catchup_schedule_instances=schedule.catchup,
50-
paused=schedule.paused,
51-
schedule_start_time=schedule.start_at,
52-
schedule_end_time=schedule.end_at,
53-
description=schedule.description,
54-
start_workflow_request=swr,
55-
)
56-
57-
58-
_DICT_KEY_MAP = {
59-
"name": ("name",),
60-
"cron_expression": ("cron_expression", "cronExpression"),
61-
"zone_id": ("zone_id", "zoneId"),
62-
"paused": ("paused",),
63-
"paused_reason": ("paused_reason", "pausedReason"),
64-
"run_catchup_schedule_instances": (
65-
"run_catchup_schedule_instances",
66-
"runCatchupScheduleInstances",
67-
),
68-
"schedule_start_time": ("schedule_start_time", "scheduleStartTime"),
69-
"schedule_end_time": ("schedule_end_time", "scheduleEndTime"),
70-
"description": ("description",),
71-
"next_run_time": ("next_run_time", "nextRunTime"),
72-
"create_time": ("create_time", "createTime"),
73-
"updated_time": ("updated_time", "updatedTime"),
74-
"created_by": ("created_by", "createdBy"),
75-
"updated_by": ("updated_by", "updatedBy"),
76-
"start_workflow_request": ("start_workflow_request", "startWorkflowRequest"),
77-
}
78-
79-
80-
def _read(ws: Any, key: str) -> Any:
81-
"""Read a field from either a WorkflowSchedule object (snake_case attrs)
82-
or a raw camelCase dict — conductor-python returns both shapes depending
83-
on the endpoint (see ``get_schedule`` vs ``get_all_schedules``)."""
84-
aliases = _DICT_KEY_MAP.get(key, (key,))
85-
if isinstance(ws, dict):
86-
for k in aliases:
87-
if k in ws:
88-
return ws[k]
89-
return None
90-
for k in aliases:
91-
if hasattr(ws, k):
92-
return getattr(ws, k)
93-
return None
94-
95-
96-
def _from_workflow_schedule(ws: Any, agent_name: Optional[str] = None) -> ScheduleInfo:
97-
"""Convert conductor-python ``WorkflowSchedule`` (or raw dict) -> ``ScheduleInfo``.
98-
99-
Handles both shapes that conductor-python returns:
100-
- ``get_all_schedules`` → ``WorkflowSchedule`` objects with snake_case attrs
101-
- ``get_schedule`` → raw dict with camelCase keys (response_type='object')
102-
103-
``agent_name`` is optional; if omitted, it's derived from
104-
``startWorkflowRequest.name``.
105-
"""
106-
swr = _read(ws, "start_workflow_request") or {}
107-
wire_name = _read(ws, "name") or ""
108-
swr_name = swr.get("name") if isinstance(swr, dict) else getattr(swr, "name", "")
109-
swr_input = swr.get("input") if isinstance(swr, dict) else getattr(swr, "input", None)
110-
agent = agent_name or swr_name or ""
111-
112-
return ScheduleInfo(
113-
name=wire_name,
114-
short_name=_unprefix(agent, wire_name),
115-
agent=swr_name or "",
116-
cron=_read(ws, "cron_expression") or "",
117-
timezone=_read(ws, "zone_id") or "UTC",
118-
input=dict(swr_input) if swr_input else {},
119-
paused=bool(_read(ws, "paused")),
120-
paused_reason=_read(ws, "paused_reason"),
121-
catchup=bool(_read(ws, "run_catchup_schedule_instances")),
122-
start_at=_read(ws, "schedule_start_time"),
123-
end_at=_read(ws, "schedule_end_time"),
124-
description=_read(ws, "description"),
125-
next_run=_read(ws, "next_run_time"),
126-
create_time=_read(ws, "create_time"),
127-
update_time=_read(ws, "updated_time"),
128-
created_by=_read(ws, "created_by"),
129-
updated_by=_read(ws, "updated_by"),
130-
)
131-
132-
133-
def _check_unique_names(schedules: Iterable[Schedule]) -> None:
134-
seen: set = set()
135-
for s in schedules:
136-
if s.name in seen:
137-
raise ScheduleNameConflict(
138-
f"Duplicate schedule name '{s.name}' — names must be unique per agent"
139-
)
140-
seen.add(s.name)
141-
142-
143-
def _translate(exc: Exception) -> Exception:
144-
"""Best-effort: map conductor-python HTTP errors to typed schedule errors."""
145-
status = getattr(exc, "status", None) or getattr(exc, "code", None)
146-
body = getattr(exc, "body", "") or str(exc)
147-
if status == 404:
148-
return ScheduleNotFound(body)
149-
if status == 400 and "cron" in body.lower():
150-
return InvalidCronExpression(body)
151-
return exc
152-
153-
15442
class AgentScheduleClient:
15543
"""Thin wrapper around Conductor's ``OrkesSchedulerClient``.
15644

src/conductor/client/orkes/orkes_scheduler_client.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,8 @@ def get_scheduler_tags(self, name: str) -> List[MetadataTag]:
142142

143143
def delete_scheduler_tags(self, tags: List[MetadataTag], name: str) -> List[MetadataTag]:
144144
self.schedulerResourceApi.delete_tag_for_schedule(tags, name)
145+
146+
def _start_workflow(self, request) -> str:
147+
# Enables SchedulerClient.run_now — OrkesBaseClient already carries the
148+
# workflow resource API.
149+
return self.workflowResourceApi.start_workflow(body=request)

0 commit comments

Comments
 (0)