Skip to content

Commit cd86749

Browse files
committed
chore: Use scheduling payload when creating runs
1 parent 21a01ed commit cd86749

10 files changed

Lines changed: 503 additions & 152 deletions

File tree

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,8 @@ codegen:
176176
find codegen/out/aignx/codegen/models/ -name "[a-z]*.py" -type f | sed 's|.*/\(.*\)\.py|\1|' | xargs -I{} echo "from .{} import *" > codegen/out/aignx/codegen/models/__init__.py
177177
# fix resource patch
178178
# in codegen/out/public_api.py replace all occurrences of resource_path='/v1 with resource_path='/api/v1
179-
# Use portable sed syntax: -i'' works on both macOS and Linux
180-
sed -i "" "s|resource_path='/v1|resource_path='/api/v1|g" codegen/out/aignx/codegen/api/public_api.py
179+
# Use portable sed syntax: try GNU-style -i'' first, then BSD/macOS-style -i ''
180+
sed -i'' "s|resource_path='/v1|resource_path='/api/v1|g" codegen/out/aignx/codegen/api/public_api.py || sed -i '' "s|resource_path='/v1|resource_path='/api/v1|g" codegen/out/aignx/codegen/api/public_api.py
181181

182182
# Special rule to catch any arguments (like patch, minor, major, pdf, Python versions, or x.y.z)
183183
# This prevents "No rule to make target" errors when passing arguments to make commands

src/aignostics/application/_gui/_page_application_run_describe.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,24 @@ def open_marimo(results_folder: Path, button: ui.button | None = None) -> None:
569569
else:
570570
status_str = f"{run_data.state.value}"
571571

572+
# Extract scheduling info from the API response field
573+
scheduling = getattr(run_data, "scheduling", None)
574+
due_date_str = "N/A"
575+
deadline_str = "N/A"
576+
if scheduling is not None:
577+
if getattr(scheduling, "due_date", None) is not None:
578+
due_date_str = (
579+
scheduling.due_date.astimezone().strftime("%m-%d %H:%M")
580+
if hasattr(scheduling.due_date, "astimezone")
581+
else str(scheduling.due_date)
582+
)
583+
if getattr(scheduling, "deadline", None) is not None:
584+
deadline_str = (
585+
scheduling.deadline.astimezone().strftime("%m-%d %H:%M")
586+
if hasattr(scheduling.deadline, "astimezone")
587+
else str(scheduling.deadline)
588+
)
589+
572590
ui.code(
573591
f"""
574592
* Run ID: {run_data.run_id}
@@ -585,6 +603,9 @@ def open_marimo(results_folder: Path, button: ui.button | None = None) -> None:
585603
- {run_data.statistics.item_system_error_count} system errors
586604
* Submitted: {submitted_at.strftime("%m-%d %H:%M")} ({run_data.submitted_by})
587605
* Terminated: {terminated_at.strftime("%m-%d %H:%M") if terminated_at else "N/A"} ({duration_str})
606+
* Scheduling:
607+
- Due Date: {due_date_str}
608+
- Deadline: {deadline_str}
588609
* Error: {run_data.error_message or "N/A"} ({run_data.error_code or "N/A"})
589610
""",
590611
language="markdown",

src/aignostics/application/_service.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
import time
66
from collections.abc import Callable, Generator
7+
from datetime import datetime
78
from http import HTTPStatus
89
from importlib.util import find_spec
910
from pathlib import Path
@@ -46,7 +47,9 @@
4647
get_mime_type_for_artifact,
4748
get_supported_extensions_for_application,
4849
is_not_terminated_with_deadline_exceeded,
50+
validate_deadline,
4951
validate_due_date,
52+
validate_scheduling_constraints,
5053
)
5154

5255
has_qupath_extra = find_spec("ijson")
@@ -569,7 +572,9 @@ def application_runs_static( # noqa: PLR0913, PLR0917
569572
"item_succeeded_count": run.statistics.item_succeeded_count,
570573
"tags": run.custom_metadata.get("sdk", {}).get("tags", []) if run.custom_metadata else [],
571574
"is_not_terminated_with_deadline_exceeded": is_not_terminated_with_deadline_exceeded(
572-
run.state, run.custom_metadata
575+
run.state,
576+
scheduling=getattr(run, "scheduling", None),
577+
custom_metadata=run.custom_metadata,
573578
),
574579
}
575580
for run in Service().application_runs(
@@ -1004,10 +1009,15 @@ def application_run_submit( # noqa: PLR0913, PLR0917, PLR0912, C901, PLR0915
10041009
the application version ID is invalid
10051010
or items invalid
10061011
or due_date not ISO 8601
1007-
or due_date not in the future.
1012+
or due_date not in the future
1013+
or deadline not ISO 8601
1014+
or deadline not in the future
1015+
or due_date not before deadline.
10081016
RuntimeError: If submitting the run failed unexpectedly.
10091017
"""
10101018
validate_due_date(due_date)
1019+
validate_deadline(deadline)
1020+
validate_scheduling_constraints(due_date, deadline)
10111021
try:
10121022
if custom_metadata is None:
10131023
custom_metadata = {}
@@ -1021,12 +1031,20 @@ def application_run_submit( # noqa: PLR0913, PLR0917, PLR0912, C901, PLR0915
10211031
sdk_metadata["workflow"] = {
10221032
"onboard_to_aignostics_portal": onboard_to_aignostics_portal,
10231033
}
1034+
# Build scheduling payload for the top-level request field (not custom_metadata)
1035+
scheduling = None
10241036
if due_date or deadline:
1025-
sdk_metadata["scheduling"] = {}
1026-
if due_date:
1027-
sdk_metadata["scheduling"]["due_date"] = due_date
1028-
if deadline:
1029-
sdk_metadata["scheduling"]["deadline"] = deadline
1037+
from aignx.codegen.models import SchedulingRequest # noqa: PLC0415
1038+
1039+
def _parse_iso(value: str | None) -> datetime | None:
1040+
if value is None:
1041+
return None
1042+
return datetime.fromisoformat(value)
1043+
1044+
scheduling = SchedulingRequest(
1045+
due_date=_parse_iso(due_date),
1046+
deadline=_parse_iso(deadline),
1047+
)
10301048

10311049
has_gpu_config = (
10321050
gpu_type or gpu_provisioning_mode or max_gpus_per_slide or flex_start_max_run_duration_minutes
@@ -1069,6 +1087,7 @@ def application_run_submit( # noqa: PLR0913, PLR0917, PLR0912, C901, PLR0915
10691087
items=items,
10701088
application_version=application_version,
10711089
custom_metadata=custom_metadata,
1090+
scheduling=scheduling,
10721091
)
10731092
except ValueError as e:
10741093
message = f"Failed to submit application run for '{application_id}' (version: {application_version}): {e}"

src/aignostics/application/_utils.py

Lines changed: 116 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from datetime import UTC, datetime
1414
from enum import StrEnum
1515
from pathlib import Path
16-
from typing import Any
16+
from typing import Any, Protocol
1717

1818
import humanize
1919
from loguru import logger
@@ -39,53 +39,123 @@
3939
RUN_FAILED_MESSAGE = "Failed to get status for run with ID '%s'"
4040

4141

42-
def validate_due_date(due_date: str | None) -> None:
43-
"""Validate that due_date is in ISO 8601 format and in the future.
42+
def _validate_scheduling_datetime(value: str | None, field_name: str) -> None:
43+
"""Validate that a scheduling datetime field is in ISO 8601 format and in the future.
4444
4545
Args:
46-
due_date (str | None): The datetime string to validate.
46+
value (str | None): The datetime string to validate.
47+
field_name (str): The name of the field (e.g. 'due_date', 'deadline') for error messages.
4748
4849
Raises:
4950
ValueError: If
50-
the format is invalid
51-
or the due_date is not in the future.
51+
the format is invalid,
52+
the datetime is timezone-naive,
53+
or the datetime is not in the future.
5254
"""
53-
if due_date is None:
55+
if value is None:
5456
return
5557

5658
# Try parsing with fromisoformat (handles most ISO 8601 formats)
5759
try:
5860
# Handle 'Z' suffix by replacing with '+00:00'
59-
normalized = due_date.replace("Z", "+00:00")
61+
normalized = value.replace("Z", "+00:00")
6062
parsed_dt = datetime.fromisoformat(normalized)
6163
except (ValueError, TypeError) as e:
6264
message = (
63-
f"Invalid ISO 8601 format for due_date. "
65+
f"Invalid ISO 8601 format for {field_name}. "
6466
f"Expected format like '2025-10-19T19:53:00+00:00' or '2025-10-19T19:53:00Z', "
65-
f"but got: '{due_date}' (error: {e})"
67+
f"but got: '{value}' (error: {e})"
6668
)
6769
raise ValueError(message) from e
6870

6971
# Ensure the datetime is timezone-aware (reject naive datetimes)
7072
if parsed_dt.tzinfo is None:
7173
message = (
72-
f"Invalid ISO 8601 format for due_date. "
74+
f"Invalid ISO 8601 format for {field_name}. "
7375
f"Expected format with timezone like '2025-10-19T19:53:00+00:00' or '2025-10-19T19:53:00Z', "
74-
f"but got: '{due_date}' (missing timezone information)"
76+
f"but got: '{value}' (missing timezone information)"
7577
)
7678
raise ValueError(message)
7779

7880
# Check that the datetime is in the future
7981
now = datetime.now(UTC)
8082
if parsed_dt <= now:
8183
message = (
82-
f"due_date must be in the future. "
83-
f"Got '{due_date}' ({parsed_dt.isoformat()}), "
84+
f"{field_name} must be in the future. "
85+
f"Got '{value}' ({parsed_dt.isoformat()}), "
8486
f"but current UTC time is {now.isoformat()}"
8587
)
8688
raise ValueError(message)
8789

8890

91+
def validate_due_date(due_date: str | None) -> None:
92+
"""Validate that due_date is in ISO 8601 format and in the future.
93+
94+
Args:
95+
due_date (str | None): The datetime string to validate.
96+
97+
Raises:
98+
ValueError: If
99+
the format is invalid
100+
or the due_date is not in the future.
101+
"""
102+
_validate_scheduling_datetime(due_date, "due_date")
103+
104+
105+
def validate_deadline(deadline: str | None) -> None:
106+
"""Validate that deadline is in ISO 8601 format and in the future.
107+
108+
Args:
109+
deadline (str | None): The datetime string to validate.
110+
111+
Raises:
112+
ValueError: If
113+
the format is invalid
114+
or the deadline is not in the future.
115+
"""
116+
_validate_scheduling_datetime(deadline, "deadline")
117+
118+
119+
def _parse_scheduling_datetime(value: str) -> datetime:
120+
"""Parse an ISO 8601 scheduling datetime string, handling 'Z' suffix.
121+
122+
Args:
123+
value (str): The datetime string to parse.
124+
125+
Returns:
126+
datetime: The parsed timezone-aware datetime.
127+
"""
128+
normalized = value.replace("Z", "+00:00")
129+
return datetime.fromisoformat(normalized)
130+
131+
132+
def validate_scheduling_constraints(due_date: str | None, deadline: str | None) -> None:
133+
"""Validate cross-field scheduling constraints.
134+
135+
When both due_date and deadline are provided, due_date must be before deadline.
136+
137+
Args:
138+
due_date (str | None): The due date string (already individually validated).
139+
deadline (str | None): The deadline string (already individually validated).
140+
141+
Raises:
142+
ValueError: If due_date is not before deadline.
143+
"""
144+
if due_date is None or deadline is None:
145+
return
146+
147+
parsed_due_date = _parse_scheduling_datetime(due_date)
148+
parsed_deadline = _parse_scheduling_datetime(deadline)
149+
150+
if parsed_due_date >= parsed_deadline:
151+
message = (
152+
f"due_date must be before deadline. "
153+
f"Got due_date='{due_date}' ({parsed_due_date.isoformat()}) "
154+
f"and deadline='{deadline}' ({parsed_deadline.isoformat()})"
155+
)
156+
raise ValueError(message)
157+
158+
89159
def validate_mappings(mappings: list[str] | None) -> None:
90160
"""Validate mapping format for file metadata amendment.
91161
@@ -124,9 +194,16 @@ def validate_mappings(mappings: list[str] | None) -> None:
124194
raise ValueError(msg) from e
125195

126196

197+
class _SchedulingLike(Protocol):
198+
"""Protocol for objects with an optional deadline attribute."""
199+
200+
deadline: datetime | None
201+
202+
127203
def is_not_terminated_with_deadline_exceeded(
128204
run_state: RunState,
129-
custom_metadata: dict[str, Any] | None,
205+
scheduling: _SchedulingLike | None = None,
206+
custom_metadata: dict[str, Any] | None = None,
130207
) -> bool | None:
131208
"""Check if the run is not terminated and the deadline has been exceeded.
132209
@@ -135,7 +212,11 @@ def is_not_terminated_with_deadline_exceeded(
135212
136213
Args:
137214
run_state (RunState): The current state of the run.
138-
custom_metadata (dict[str, Any] | None): The custom metadata containing optional deadline information.
215+
scheduling: The scheduling response object from the API (has .deadline attribute),
216+
or None if no scheduling constraints were set.
217+
custom_metadata (dict[str, Any] | None): Legacy fallback - the custom metadata containing
218+
optional deadline information in sdk.scheduling.deadline. Used only when scheduling
219+
response field is not available.
139220
140221
Returns:
141222
bool | None: True if run is not terminated and deadline exceeded,
@@ -146,16 +227,29 @@ def is_not_terminated_with_deadline_exceeded(
146227
if run_state == RunState.TERMINATED:
147228
return None
148229

149-
if not custom_metadata:
150-
return None
151-
152-
deadline_str = custom_metadata.get("sdk", {}).get("scheduling", {}).get("deadline")
153-
if not deadline_str:
230+
# Try the first-class scheduling response field first
231+
deadline_value = None
232+
if scheduling is not None:
233+
deadline_value = getattr(scheduling, "deadline", None)
234+
235+
# Fallback to custom_metadata for backward compatibility with older runs
236+
if deadline_value is None and custom_metadata:
237+
deadline_str = custom_metadata.get("sdk", {}).get("scheduling", {}).get("deadline")
238+
if deadline_str:
239+
try:
240+
deadline_value = _parse_scheduling_datetime(deadline_str)
241+
except (ValueError, TypeError, AttributeError):
242+
return None
243+
244+
if deadline_value is None:
154245
return None
155246

156247
try:
157248
now = datetime.now(tz=UTC)
158-
deadline_dt = datetime.fromisoformat(deadline_str)
249+
if isinstance(deadline_value, datetime):
250+
return now > deadline_value
251+
# Handle string values
252+
deadline_dt = _parse_scheduling_datetime(str(deadline_value))
159253
return now > deadline_dt
160254
except (ValueError, TypeError, AttributeError):
161255
# Invalid deadline format, return None

src/aignostics/platform/resources/runs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
RunCreationRequest,
2424
RunCreationResponse,
2525
RunState,
26+
SchedulingRequest,
2627
)
2728
from aignx.codegen.models import (
2829
ItemResultReadResponse as ItemResultData,
@@ -528,6 +529,7 @@ def submit(
528529
items: list[ItemCreationRequest],
529530
application_version: str | None = None,
530531
custom_metadata: dict[str, Any] | None = None,
532+
scheduling: SchedulingRequest | None = None,
531533
) -> Run:
532534
"""Submit a new application run.
533535
@@ -537,6 +539,9 @@ def submit(
537539
application_version (str|None): The version of the application to use.
538540
If None, the latest version is used.
539541
custom_metadata (dict[str, Any] | None): Optional metadata to attach to the run.
542+
scheduling (SchedulingRequest | None): Optional scheduling constraints for the run.
543+
Supports 'due_date' (requested completion time, ISO 8601) and
544+
'deadline' (hard deadline, ISO 8601).
540545
541546
Returns:
542547
Run: The submitted application run.
@@ -557,6 +562,7 @@ def submit(
557562
version_number=application_version,
558563
custom_metadata=cast("dict[str, Any]", convert_to_json_serializable(custom_metadata)),
559564
items=items,
565+
scheduling=scheduling,
560566
)
561567
current_settings = settings()
562568
self._validate_input_items(payload)

tests/aignostics/application/cli_pipeline_validation_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ def test_cli_run_submit_succeeds_with_valid_pipeline_config(runner: CliRunner, t
202202
HETA_APPLICATION_ID,
203203
str(csv_path),
204204
"--deadline",
205-
(datetime.now(tz=UTC) + timedelta(seconds=0)).isoformat(),
205+
(datetime.now(tz=UTC) + timedelta(seconds=5)).isoformat(),
206206
"--gpu-type",
207207
"L4",
208208
"--gpu-provisioning-mode",

tests/aignostics/application/gui_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,8 +281,10 @@ async def test_gui_download_dataset_via_application_to_run_cancel_to_find_back(
281281

282282
await user.should_see("Hard Deadline")
283283
await user.should_see("The platform might cancel the run if not completed by this time.", retries=100)
284+
time_due_date: ui.time = user.find(marker="TIME_DUE_DATE").elements.pop()
285+
time_due_date.value = (datetime.now().astimezone() + timedelta(hours=6)).strftime("%Y-%m-%d %H:%M")
284286
time_deadline: ui.time = user.find(marker="TIME_DEADLINE").elements.pop()
285-
time_deadline.value = (datetime.now().astimezone() + timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M")
287+
time_deadline.value = (datetime.now().astimezone() + timedelta(hours=12)).strftime("%Y-%m-%d %H:%M")
286288

287289
user.find(marker="BUTTON_SCHEDULING_NEXT").click()
288290
await assert_notified(user, "Prepared upload UI.")

0 commit comments

Comments
 (0)