envloop-cli-experiment 1.23.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.
@@ -0,0 +1,471 @@
1
+ import math
2
+ import time
3
+ import tomllib
4
+ from collections.abc import Mapping, Sequence
5
+ from pathlib import Path
6
+ from typing import Any, BinaryIO, Protocol, cast
7
+ from uuid import uuid4
8
+
9
+ from envloop.downloads import ArtifactError, artifact_path, download_file
10
+ from envloop.utils.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ class TaskService(Protocol):
15
+ def list_trials(self, **kwargs: Any) -> dict[str, Any]: ...
16
+ def get_task_run(self, trial_id: str) -> dict[str, Any]: ...
17
+ def get_trial_detail(self, trial_id: str) -> dict[str, Any]: ...
18
+ def get_trial_training(self, trial_id: str) -> dict[str, Any]: ...
19
+ def list_trial_metrics(self, trial_id: str, **kwargs: Any) -> dict[str, Any]: ...
20
+ def list_trial_checkpoints(self, trial_id: str, **kwargs: Any) -> dict[str, Any]: ...
21
+ def get_task_result(self, trial_id: str) -> dict[str, Any]: ...
22
+ def get_task_snapshot(self, trial_id: str) -> dict[str, Any]: ...
23
+ def stop_task(self, trial_id: str) -> dict[str, Any]: ...
24
+ def list_trial_artifacts(self, trial_id: str, **kwargs: Any) -> dict[str, Any]: ...
25
+ def open_trial_artifact(self, trial_id: str, artifact_id: str) -> BinaryIO: ...
26
+
27
+
28
+ def load_task(source: str | Path | Mapping[str, Any]) -> dict[str, Any]:
29
+ """Read a two-file UTF-8 runner, with legacy task.toml compatibility."""
30
+ if isinstance(source, Mapping):
31
+ return dict(source)
32
+ directory = Path(source)
33
+ runner_config, legacy_config = directory / "runner.toml", directory / "task.toml"
34
+ if (runner_config.exists() or runner_config.is_symlink()) and (
35
+ legacy_config.exists() or legacy_config.is_symlink()
36
+ ):
37
+ raise ValueError("runner.toml and task.toml cannot coexist")
38
+ config_path = (
39
+ runner_config if runner_config.exists() or runner_config.is_symlink()
40
+ else legacy_config
41
+ )
42
+ if config_path.is_symlink() or not config_path.is_file():
43
+ raise ValueError(f"missing regular runner.toml or task.toml: {directory}")
44
+ config = config_path.read_text(encoding="utf-8")
45
+ execution = tomllib.loads(config).get("execution", {})
46
+ if not isinstance(execution, dict):
47
+ raise ValueError("[execution] must be a table")
48
+ entry = execution.get("entrypoint")
49
+ if entry not in {"main.py", "main.sh"}:
50
+ raise ValueError("entrypoint must be main.py or main.sh")
51
+ path = directory / entry
52
+ if path.is_symlink() or not path.is_file():
53
+ raise ValueError(f"missing regular entrypoint: {entry}")
54
+ names = {p.name for p in directory.iterdir() if p.is_file() or p.is_symlink()}
55
+ if names != {config_path.name, entry}:
56
+ raise ValueError(
57
+ "runner directory must contain exactly its config and entrypoint"
58
+ )
59
+ return {config_path.name: config, entry: path.read_text(encoding="utf-8")}
60
+
61
+
62
+ def validate_command(command: str) -> str:
63
+ """Preserve shell text while rejecting malformed or oversized overrides."""
64
+ if not isinstance(command, str) or not command.strip() or "\x00" in command:
65
+ raise ValueError("command must be non-empty UTF-8 shell text without NUL")
66
+ try:
67
+ encoded = command.encode("utf-8")
68
+ except UnicodeEncodeError as exc:
69
+ raise ValueError("command must be valid UTF-8 shell text") from exc
70
+ if len(encoded) > 65536:
71
+ raise ValueError("command must not exceed 65536 UTF-8 bytes")
72
+ return command
73
+
74
+
75
+ def dataset_selector(dataset: str | Mapping[str, Any], **selection: Any) -> dict[str, Any]:
76
+ """Package a catalog selection; the platform resolves and freezes it."""
77
+ if isinstance(dataset, str):
78
+ name, version = dataset, None
79
+ if "@" in dataset:
80
+ name, version = dataset.rsplit("@", 1)
81
+ if (not name.strip() or name != name.strip()
82
+ or (version is not None and (not version.strip() or version != version.strip()))):
83
+ raise ValueError("dataset must be a non-empty name or name@version")
84
+ selector = {"name": name}
85
+ if version is not None:
86
+ selector["version"] = version
87
+ elif isinstance(dataset, Mapping):
88
+ selector = dict(dataset)
89
+ else:
90
+ raise ValueError("dataset must be a name or selector")
91
+ for key, value in selection.items():
92
+ if value is not None:
93
+ if key in selector and selector[key] != value:
94
+ raise ValueError(f"conflicting dataset selection: {key}")
95
+ selector[key] = value
96
+ return selector
97
+
98
+
99
+ class Tasks:
100
+ def __init__(self, server: TaskService) -> None:
101
+ self._server = server
102
+ from .http import configure_transport
103
+ configure_transport(self._server)
104
+
105
+ def list(self, **kwargs: Any) -> dict[str, Any]:
106
+ """List Agent and Train Trials; filter by ``type`` and/or ``job_id``."""
107
+ return self._server.list_trials(**kwargs)
108
+
109
+ def snapshot(self, trial_id: str) -> dict[str, Any]:
110
+ return self._server.get_task_snapshot(trial_id)
111
+
112
+ def status(self, trial_id: str) -> dict[str, Any]:
113
+ return self._server.get_task_run(trial_id)
114
+
115
+ def detail(self, trial_id: str) -> dict[str, Any]:
116
+ """Read common identity and the server's type-specific detail."""
117
+ return self._server.get_trial_detail(trial_id)
118
+
119
+ def training(self, trial_id: str) -> dict[str, Any]:
120
+ """Read the training extension of a Train Trial."""
121
+ return self._server.get_trial_training(trial_id)
122
+
123
+ def metrics(self, trial_id: str, **kwargs: Any) -> dict[str, Any]:
124
+ """Read one page of metrics belonging to this Trial."""
125
+ return self._server.list_trial_metrics(trial_id, **kwargs)
126
+
127
+ def checkpoints(self, trial_id: str, **kwargs: Any) -> dict[str, Any]:
128
+ """Read one page of checkpoints belonging to this Trial."""
129
+ return self._server.list_trial_checkpoints(trial_id, **kwargs)
130
+
131
+ def result(self, trial_id: str) -> dict[str, Any]:
132
+ return self._server.get_task_result(trial_id)
133
+
134
+ def stop(self, trial_id: str) -> dict[str, Any]:
135
+ return self._server.stop_task(trial_id)
136
+
137
+ def artifacts(self, trial_id: str, **kwargs: Any) -> dict[str, Any]:
138
+ """One page of the Trial's artifact listing (``cursor`` / ``limit``)."""
139
+ return self._server.list_trial_artifacts(trial_id, **kwargs)
140
+
141
+
142
+
143
+
144
+ def _all_artifacts(self, trial_id: str):
145
+ items, cursor = cast(list[dict[str, Any]], []), None
146
+ while True:
147
+ page = self.artifacts(trial_id, cursor=cursor, limit=100)
148
+ items.extend(page["items"])
149
+ cursor = page.get("next_cursor")
150
+ if cursor is None:
151
+ return items
152
+
153
+
154
+ def download_artifact(self, trial_id: str, artifact: str | Mapping[str, Any], dest: str | Path) -> dict[str, Any]:
155
+ """Download a listed Trial artifact, verifying its size, digest and path."""
156
+ item = artifact if isinstance(artifact, Mapping) else next(
157
+ (entry for entry in self._all_artifacts(trial_id) if entry.get('artifact_id') == artifact), None)
158
+ if item is None:
159
+ raise ArtifactError('ARTIFACT_NOT_FOUND')
160
+ return download_file(lambda: self._server.open_trial_artifact(trial_id, item['artifact_id']), item, dest)
161
+
162
+ def download_artifacts(self, trial_id: str, dest: str | Path) -> dict[str, Any]:
163
+ """Download every uploaded artifact of a Trial below ``dest``.
164
+
165
+ All names are checked before any file is written. Artifacts that are not
166
+ ``uploaded`` (pending, failed or deleted) are reported in ``skipped``.
167
+ """
168
+ root = Path(dest)
169
+ items = self._all_artifacts(trial_id)
170
+ for item in items:
171
+ artifact_path(root, item.get("name"))
172
+ downloaded, skipped = cast(list[dict[str, Any]], []), cast(list[dict[str, Any]], [])
173
+ for item in items:
174
+ if item.get("upload_state") == "uploaded" and item.get("artifact_id"):
175
+ downloaded.append(self.download_artifact(trial_id, item, root))
176
+ else:
177
+ skipped.append({"artifact_id": item.get("artifact_id"), "name": item["name"],
178
+ "upload_state": item.get("upload_state")})
179
+ return {"trial_id": trial_id, "directory": str(root), "downloaded": downloaded, "skipped": skipped}
180
+
181
+
182
+ def wait(
183
+ self, trial_id: str, *, timeout: float = 30, poll_interval: float = 0.05
184
+ ) -> dict[str, Any]:
185
+ if not math.isfinite(timeout) or timeout < 0:
186
+ raise ValueError("timeout must be finite and non-negative")
187
+ if not math.isfinite(poll_interval) or poll_interval <= 0:
188
+ raise ValueError("poll_interval must be finite and positive")
189
+ logger.info("task.wait_started trial_id=%s timeout_sec=%s", trial_id, timeout)
190
+ deadline = time.monotonic() + timeout
191
+ previous_status = None
192
+ while True:
193
+ trial = self.status(trial_id)
194
+ if trial["status"] != previous_status:
195
+ logger.debug(
196
+ "task.status_changed trial_id=%s status=%s",
197
+ trial_id,
198
+ trial["status"],
199
+ )
200
+ previous_status = trial["status"]
201
+ if trial["status"] in {"succeeded", "failed", "cancelled"}:
202
+ logger.info(
203
+ "task.wait_finished trial_id=%s status=%s",
204
+ trial_id,
205
+ trial["status"],
206
+ )
207
+ return trial
208
+ remaining = deadline - time.monotonic()
209
+ if remaining <= 0:
210
+ logger.warning(
211
+ "task.wait_timed_out trial_id=%s timeout_sec=%s", trial_id, timeout
212
+ )
213
+ raise TimeoutError(f"trial {trial_id} did not finish within {timeout}s")
214
+ time.sleep(min(poll_interval, remaining))
215
+
216
+
217
+ def runner_selector(value: str):
218
+ """Require an explicit, portable revision; resolution belongs to the API."""
219
+ if not isinstance(value, str):
220
+ raise ValueError("runner requires name@revision")
221
+ name, separator, revision = value.partition("@")
222
+ if (not separator or not name or "/" not in name or not revision
223
+ or value != value.strip() or any(char.isspace() for char in value)
224
+ or revision in {"HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"}
225
+ or revision.startswith("-") or ".." in revision or revision == "@" or "@{" in revision
226
+ or any(char in revision for char in "~^:{}?*[\\")):
227
+ raise ValueError("runner requires name@revision with an explicit commit, tag or branch")
228
+ return value
229
+
230
+
231
+ def job_budget(max_cost_usd: float):
232
+ """The public ``budget`` of an Agent/Sandbox Job: a USD cap from 0.000001 (one micro-USD) to 100000."""
233
+ if (type(max_cost_usd) not in (int, float) or not math.isfinite(max_cost_usd)
234
+ or not 0.000001 <= max_cost_usd <= 100_000):
235
+ raise ValueError("max_cost_usd must be a USD amount from 0.000001 to 100000")
236
+ return {"max_cost_usd": max_cost_usd}
237
+
238
+
239
+ class Jobs:
240
+ def __init__(self, server: Any):
241
+ self._server = server
242
+ from .http import configure_transport
243
+ configure_transport(self._server)
244
+
245
+ def submit(self, dataset: str | Mapping[str, Any] | None = None, *, source: Any = None,
246
+ task_ref: Mapping[str, Any] | None = None, task_id: str | None = None,
247
+ request_id: str | None = None, attempts: int = 1, namespace: str | None = None,
248
+ config: str | None = None, split: str | None = None, revision: str | None = None,
249
+ runner: str | None = None,
250
+ profile_id: str | None = None, job_id: str | None = None, tag: str | None = None,
251
+ queue_id: str = "default",
252
+ priority: int = 0, command: str | None = None, source_train_job_id: str | None = None,
253
+ source_train_trial_id: str | None = None,
254
+ type: str = 'agent', image: str | None = None, params: Mapping[str, Any] | None = None,
255
+ max_cost_usd: float | None = None, **options: Any):
256
+ """Submit an Agent Job or a Sandbox Job using a registered Runner.
257
+
258
+ A source Trial requires its Train Job ID. The API verifies source type,
259
+ ownership and project scope, and resolves any legacy Train Job alias.
260
+ ``max_cost_usd`` caps the Job's model plus sandbox spend; the Job stops with
261
+ ``error=BUDGET_EXCEEDED`` when it is reached and its result reports ``cost``.
262
+ """
263
+ if type not in {'agent', 'sandbox'}:
264
+ raise ValueError('Jobs.submit type must be agent or sandbox; use training_jobs.submit for train')
265
+ if image is not None and type != 'sandbox':
266
+ raise ValueError('image requires a Sandbox Job')
267
+ for field, value in (("source_train_job_id", source_train_job_id),
268
+ ("source_train_trial_id", source_train_trial_id)):
269
+ if value is not None and (not isinstance(value, str) or not value.strip()):
270
+ raise ValueError(f"{field} must be a non-empty string")
271
+ if source_train_trial_id is not None and source_train_job_id is None:
272
+ raise ValueError("source_train_trial_id requires source_train_job_id")
273
+ if request_id is not None and (not isinstance(request_id, str) or not request_id.strip() or len(request_id) > 128):
274
+ raise ValueError("request_id must be a non-empty string of at most 128 characters")
275
+ if params is not None:
276
+ from envloop_experiment.runtime_params import validate_params
277
+ options['params'] = validate_params(params)
278
+ if max_cost_usd is not None:
279
+ options['budget'] = job_budget(max_cost_usd)
280
+ if type == 'sandbox':
281
+ from envloop.http import validate_public_execution
282
+ from envloop_experiment.sandbox import (
283
+ sandbox_runner_selector,
284
+ validate_image,
285
+ )
286
+
287
+ if any(value is not None for value in (dataset, source, task_ref, task_id, namespace,
288
+ config, split, revision, source_train_job_id, source_train_trial_id)):
289
+ raise ValueError('Sandbox Jobs accept a Runner without Dataset, Task or training-source options')
290
+ if any(key in options for key in ('seed', 'profile_name', 'profile_version')):
291
+ raise ValueError('Sandbox profiles must use profile_id with a registered Runner')
292
+ if any(key in options for key in ('executor', 'provider')):
293
+ raise ValueError('execution backend is configured by the server')
294
+ body = {'type': 'sandbox', 'runner': sandbox_runner_selector(runner),
295
+ 'request_id': request_id if request_id is not None else str(uuid4()),
296
+ 'attempts': attempts, 'queue_id': queue_id, 'priority': priority, **options}
297
+ for key, value in dict(profile_id=profile_id, job_id=job_id, tag=tag).items():
298
+ if value is not None:
299
+ body[key] = value
300
+ if command is not None:
301
+ body['command'] = validate_command(command)
302
+ if image is not None:
303
+ body['image'] = validate_image(image)
304
+ validate_public_execution(body)
305
+ return self._server.submit_job(body)
306
+ profile_only = all(value is None for value in (dataset, source, task_ref)) and (profile_id is not None or options.get("profile_name") is not None)
307
+ if sum(value is not None for value in (dataset, source, task_ref)) != 1 and not profile_only:
308
+ raise ValueError("provide exactly one dataset, source or task_ref")
309
+ if dataset is None and not profile_only and (runner is not None or any(value is not None for value in (task_id, namespace, config, split, revision))):
310
+ raise ValueError("dataset selection options require a dataset")
311
+ if runner is not None:
312
+ runner = runner_selector(runner)
313
+ if command is not None:
314
+ options["command"] = validate_command(command)
315
+ selection = {key: value for key, value in dict(task_id=task_id, namespace=namespace, config=config,
316
+ split=split, revision=revision).items() if value is not None}
317
+ if profile_only:
318
+ source = {"dataset": selection} if selection else {}
319
+ elif source is not None:
320
+ source = {"task": load_task(source)}
321
+ elif task_ref is not None:
322
+ source = {"task_ref": dict(task_ref)}
323
+ elif isinstance(dataset, str):
324
+ source = {"dataset": dataset_selector(dataset, **selection)}
325
+ elif isinstance(dataset, Mapping):
326
+ if "kind" not in dataset:
327
+ source = {"dataset": dataset_selector(dataset, **selection)}
328
+ elif selection:
329
+ raise ValueError("selection options require a dataset name; fixed references already freeze members")
330
+ else:
331
+ source = {"dataset_ref": dict(dataset)}
332
+ else:
333
+ raise ValueError("dataset must be a fixed reference, name or name@version")
334
+ for key, value in dict(runner=runner, profile_id=profile_id,
335
+ job_id=job_id, tag=tag, source_train_job_id=source_train_job_id,
336
+ source_train_trial_id=source_train_trial_id).items():
337
+ if value is not None:
338
+ options[key] = value
339
+ # Omit new defaults on legacy submissions to retain older API compatibility.
340
+ if runner is not None or queue_id != "default":
341
+ options["queue_id"] = queue_id
342
+ if runner is not None or priority != 0:
343
+ options["priority"] = priority
344
+ # Resolve on the platform after its request_id check, so a retry retains
345
+ # its first frozen snapshot even when the source/default has changed.
346
+ logger.info("job.submitting attempts=%s", attempts)
347
+ job = self._server.submit_job({"request_id": request_id if request_id is not None else str(uuid4()),
348
+ **source, "attempts": attempts, **options})
349
+ logger.info("job.submitted job_id=%s", job["job_id"])
350
+ return job
351
+
352
+ def submit_spec(self, body: dict[str, Any], *, params: Mapping[str, Any] | None = None):
353
+ """Submit an Agent/Sandbox JSON request without dropping declared fields."""
354
+ if not isinstance(body, dict) or body.get('type', 'agent') not in {'agent', 'sandbox'}:
355
+ raise ValueError('submit_spec requires an Agent or Sandbox Job object')
356
+ from envloop_experiment.runtime_params import merge_params
357
+ if 'params' in body or params is not None:
358
+ body = {**body, 'params': merge_params(body.get('params', {}), params or {})}
359
+ request_id = body.get('request_id', str(uuid4()))
360
+ if not isinstance(request_id, str) or not request_id.strip() or len(request_id) > 128:
361
+ raise ValueError('request_id must be a non-empty string of at most 128 characters')
362
+ return self._server.submit_job({'request_id': request_id, **body})
363
+
364
+ def regrade(self, *, job_id: str | None = None, trial_ids: Sequence[str] | None = None,
365
+ reward_set: dict[str, Any] | None = None, runner_ref: str | None = None,
366
+ runner: str | None = None, budget: dict[str, Any] | None = None,
367
+ request_id: str | None = None):
368
+ """Re-run only the verifier of sealed compose Trials, optionally with a reward set version.
369
+
370
+ ``job_id`` regrades every sealed Trial of an Agent Job; ``trial_ids`` names Trials that must all be
371
+ eligible. ``reward_set`` is ``{"uri", "manifest_sha256"}``; ``None`` uses each Task's own tests.
372
+ Source Trials are never changed: every new score is a new Trial in the returned regrade Job.
373
+ ``runner`` (``NAME@REVISION|branch|tag``) or ``runner_ref`` selects a Runner Revision that declares
374
+ regrade support; Trials sealed before that capability existed need one. The default ``request_id`` is
375
+ derived from the request, so retrying the same regrade is idempotent; pass a new one to run it again.
376
+ """
377
+ if (job_id is None) == (trial_ids is None):
378
+ raise ValueError('regrade needs exactly one of job_id or trial_ids')
379
+ if trial_ids is not None and (not isinstance(trial_ids, (list, tuple)) or not 1 <= len(trial_ids) <= 1000
380
+ or any(not isinstance(item, str) or not item for item in trial_ids)):
381
+ raise ValueError('trial_ids must name 1 to 1000 Trials')
382
+ if reward_set is not None and (not isinstance(reward_set, dict) or set(reward_set) != {'uri', 'manifest_sha256'}):
383
+ raise ValueError('reward_set must be {"uri": ..., "manifest_sha256": ...}')
384
+ if runner is not None and runner_ref is not None:
385
+ raise ValueError('give either runner or runner_ref, not both')
386
+ if runner is not None and (not isinstance(runner, str) or '@' not in runner):
387
+ raise ValueError('runner must be NAME@REVISION, branch or tag')
388
+ source = {'job_id': job_id} if job_id is not None else {'trial_ids': list(cast(Sequence[str], trial_ids))}
389
+ if request_id is None:
390
+ import hashlib
391
+ import json
392
+ identity = json.dumps({'source': source, 'reward_set': reward_set, 'runner': runner,
393
+ 'runner_ref': runner_ref, 'budget': budget}, sort_keys=True, separators=(',', ':'))
394
+ request_id = 'regrade-' + hashlib.sha256(identity.encode()).hexdigest()[:32]
395
+ if not isinstance(request_id, str) or not request_id.strip() or len(request_id) > 128:
396
+ raise ValueError('request_id must be a non-empty string of at most 128 characters')
397
+ body = {'type': 'regrade', 'request_id': request_id, 'source': source, 'reward_set': reward_set}
398
+ for key, value in (('runner_ref', runner_ref), ('runner', runner), ('budget', budget)):
399
+ if value is not None:
400
+ body[key] = value
401
+ return self._server.submit_job(body)
402
+
403
+ def submit_inference(self, body: dict[str, Any]):
404
+ """Submit a continuous inference deployment, identified by its Job ID."""
405
+ from envloop_experiment.inference import validate_submission
406
+ validate_submission(body)
407
+ return self._server.submit_job({'request_id': str(uuid4()), **body, 'type': 'inference'})
408
+
409
+ def pause(self, job_id: str):
410
+ return self._server.job_action(job_id, 'pause')
411
+
412
+ def resume(self, job_id: str):
413
+ return self._server.job_action(job_id, 'resume')
414
+
415
+ def wake(self, job_id: str):
416
+ return self._server.job_action(job_id, 'wake')
417
+
418
+ def events(self, job_id: str):
419
+ return self._server.job_events(job_id)
420
+
421
+ def status(self, job_id: str):
422
+ return self._server.get_job(job_id)
423
+
424
+ def result(self, job_id: str):
425
+ return self._server.get_job_result(job_id)
426
+
427
+ def list(self, **kwargs: Any):
428
+ return self._server.list_jobs(**kwargs)
429
+
430
+ def logs(self, job_id: str, *, source: str = 'all', cursor: str | None = None, tail: int | None = None,
431
+ limit: int = 200, process: str | None = None):
432
+ """Read captured Job output, retaining pagination and collection metadata.
433
+
434
+ ``process`` selects one recorded process, e.g. ``"trainer"`` for a Train Job's step metrics.
435
+ """
436
+ from envloop_experiment.job_logs import log_query
437
+
438
+ return self._server.get_job_logs(job_id, **log_query(source=source, cursor=cursor, tail=tail, limit=limit,
439
+ process=process))
440
+
441
+ def stop(self, job_id: str):
442
+ return self._server.stop_job(job_id)
443
+
444
+ def wait(self, job_id: str, *, timeout: float = 600, poll_interval: float = .1, max_poll_interval: float = 5):
445
+ """Poll until terminal; the interval grows 1.5x per poll up to ``max_poll_interval``.
446
+
447
+ Jobs that run for tens of minutes (large-model training) would otherwise
448
+ issue ten status requests a second for the whole wait.
449
+ """
450
+ if not math.isfinite(timeout) or timeout < 0:
451
+ raise ValueError("timeout must be finite and non-negative")
452
+ if not math.isfinite(poll_interval) or poll_interval <= 0:
453
+ raise ValueError("poll_interval must be finite and positive")
454
+ if not math.isfinite(max_poll_interval) or max_poll_interval <= 0:
455
+ raise ValueError("max_poll_interval must be finite and positive")
456
+ deadline = time.monotonic() + timeout
457
+ while True:
458
+ job = self.status(job_id)
459
+ if job["status"] in {"completed", "failed", "cancelled"}:
460
+ return job
461
+ remaining = deadline - time.monotonic()
462
+ if remaining <= 0:
463
+ raise TimeoutError("job did not reach a terminal state")
464
+ time.sleep(min(poll_interval, remaining))
465
+ poll_interval = max(poll_interval, min(poll_interval * 1.5, max_poll_interval))
466
+
467
+
468
+
469
+
470
+ class RpcService(TaskService, Protocol):
471
+ """Transport-independent execution service contract."""
@@ -0,0 +1,20 @@
1
+ """Thin Job-bound decision transport. Business logic stays in approved plugins."""
2
+ from typing import Any
3
+ from urllib.parse import quote
4
+
5
+
6
+ class DecisionClient:
7
+ def __init__(self, transport: Any):
8
+ self.transport = transport
9
+
10
+ @staticmethod
11
+ def validate(job_id: str, model: str, payload: dict[str, Any]):
12
+ if (not isinstance(job_id, str) or not job_id.strip()
13
+ or not isinstance(model, str) or not model.strip()
14
+ or not isinstance(payload, dict) or 'model' in payload):
15
+ raise ValueError('decision requires job_id, model and a payload object without model')
16
+
17
+ def evaluate(self, job_id: str, *, model: str, payload: dict[str, Any]):
18
+ self.validate(job_id, model, payload)
19
+ return self.transport.request('POST', f'/v1/job/{quote(job_id, safe="")}/Inference/decision',
20
+ {'model': model, **payload})
@@ -0,0 +1,142 @@
1
+ import shlex
2
+ from collections.abc import Callable
3
+ from http.client import HTTPException, InvalidURL
4
+ from typing import Any, cast
5
+ from urllib.error import HTTPError, URLError
6
+ from urllib.parse import quote, urlencode
7
+ from urllib.request import Request
8
+
9
+ from envloop.http import (
10
+ REDIRECT_STATUSES,
11
+ Download,
12
+ RemoteError,
13
+ transport_error,
14
+ validate_public_execution,
15
+ )
16
+
17
+
18
+ class Endpoints:
19
+ request: Callable[..., Any]
20
+ base: str
21
+ headers: dict[str, str]
22
+ opener: Any
23
+ timeout: float
24
+ remote_error: Callable[[HTTPError], BaseException]
25
+
26
+ def submit_job(self, body: dict[str, Any]):
27
+ validate_public_execution(body)
28
+ if 'params' in body:
29
+ from envloop_experiment.runtime_params import validate_params
30
+ body = {**body, 'params': validate_params(body['params'])}
31
+ configure_transport(self)
32
+ return self.request("POST", "/v1/job", body)
33
+
34
+
35
+ def get_job(self, job_id: str):
36
+ return self.request("GET", f"/v1/job/{quote(job_id, safe='')}")
37
+
38
+
39
+ def get_job_result(self, job_id: str):
40
+ return self.request("GET", f"/v1/job/{quote(job_id, safe='')}/result")
41
+
42
+
43
+ def get_job_logs(self, job_id: str, **kwargs: Any):
44
+ return self.request("GET", f"/v1/job/{quote(job_id, safe='')}/logs?" + urlencode(kwargs))
45
+
46
+
47
+ def list_jobs(self, **kwargs: Any):
48
+ return self.request("GET", "/v1/job?" + urlencode({key: value for key, value in kwargs.items() if value is not None}))
49
+
50
+
51
+ def job_action(self, job_id: str, action: str):
52
+ return self.request('POST', f"/v1/job/{quote(job_id, safe='')}/{action}", {})
53
+
54
+
55
+ def job_events(self, job_id: str):
56
+ return self.request('GET', f"/v1/job/{quote(job_id, safe='')}/events")
57
+
58
+
59
+ def stop_job(self, job_id: str):
60
+ return self.request("POST", f"/v1/job/{quote(job_id, safe='')}/stop")
61
+
62
+
63
+ def list_trials(self, **kwargs: Any):
64
+ return self.request("GET", "/v1/trials?" + urlencode({key: value for key, value in kwargs.items() if value is not None}))
65
+
66
+
67
+ def get_task_run(self, trial_id: str):
68
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}")
69
+
70
+
71
+ def get_trial_detail(self, trial_id: str):
72
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/detail")
73
+
74
+
75
+ def get_trial_training(self, trial_id: str):
76
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/training")
77
+
78
+
79
+ def list_trial_metrics(self, trial_id: str, **kwargs: Any):
80
+ query = urlencode({key: value for key, value in kwargs.items() if value is not None})
81
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/metrics?{query}")
82
+
83
+
84
+ def list_trial_checkpoints(self, trial_id: str, **kwargs: Any):
85
+ query = urlencode({key: value for key, value in kwargs.items() if value is not None})
86
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/checkpoints?{query}")
87
+
88
+
89
+ def get_task_result(self, trial_id: str):
90
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/result")
91
+
92
+
93
+ def get_task_snapshot(self, trial_id: str):
94
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/snapshot")
95
+
96
+
97
+ def stop_task(self, trial_id: str):
98
+ return self.request("POST", f"/v1/trials/{quote(trial_id, safe='')}/stop")
99
+
100
+
101
+ def list_trial_artifacts(self, trial_id: str, **kwargs: Any):
102
+ return self.request("GET", f"/v1/trials/{quote(trial_id, safe='')}/artifacts?"
103
+ + urlencode({key: value for key, value in kwargs.items() if value is not None}))
104
+
105
+
106
+ def open_trial_artifact(self, trial_id: str, artifact_id: str):
107
+ """Open an artifact through APIServer; storage redirects are refused."""
108
+ url = self.base + f"/v1/trials/{quote(trial_id, safe='')}/artifacts/{quote(artifact_id, safe='')}/content"
109
+ headers = {key: value for key, value in self.headers.items() if key != "Content-Type"}
110
+ try:
111
+ try:
112
+ return Download(self.opener.open(Request(url, headers=headers), timeout=self.timeout))
113
+ except HTTPError as error:
114
+ if error.code in REDIRECT_STATUSES:
115
+ error.close()
116
+ raise RemoteError('DATA_REDIRECT_REJECTED', error.code) from None
117
+ raise self.remote_error(error) from None
118
+ except (URLError, TimeoutError, OSError, InvalidURL, HTTPException, ValueError) as error:
119
+ raise transport_error(error) from None
120
+
121
+
122
+
123
+
124
+ def configure_transport(transport: Any):
125
+ from envloop.http import HttpService
126
+ if isinstance(transport, HttpService):
127
+ cast(Any, transport)._request_policy = request_policy
128
+
129
+
130
+ def request_policy(self: Any, method: str, path: str, body: Any, headers: Any):
131
+ from envloop import http as base_http
132
+ if method != 'POST' or path != '/v1/job':
133
+ return self._send(method, path, body, headers, self.timeout)
134
+ try:
135
+ return self._send(method, path, body, headers, max(self.timeout, getattr(base_http, "SUBMIT_TIMEOUT_SEC", 120)))
136
+ except RemoteError as error:
137
+ submission = body.get("request_id") if isinstance(body, dict) else None
138
+ if error.status_code is None and isinstance(submission, str) and 0 < len(submission) <= 128 and submission.isprintable():
139
+ cast(Any, error).submission_request_id = submission
140
+ error.hint = (f"The platform may already have created this Job. Retry with --request-id {shlex.quote(submission)} "
141
+ "to reuse this submission instead of creating a duplicate.")
142
+ raise
@@ -0,0 +1,36 @@
1
+ """Public inference intent validation; resolution and admission remain server owned."""
2
+ import re
3
+ from typing import Any
4
+
5
+ _FIELDS = {'name', 'runner', 'inputs', 'serving_profile', 'hardware', 'weights_format',
6
+ 'scaling', 'temporary_scope_id', 'profile'}
7
+
8
+
9
+ def validate_submission(body: Any):
10
+ if (not isinstance(body, dict) or set(body) - {'type', 'request_id', 'spec'}
11
+ or body.get('type', 'inference') != 'inference'):
12
+ raise ValueError('inference submission requires spec and request_id')
13
+ spec = body.get('spec')
14
+ if not isinstance(spec, dict):
15
+ raise ValueError('inference submission requires a spec object')
16
+ if 'request_id' in body and (not isinstance(body['request_id'], str)
17
+ or not body['request_id'].strip() or len(body['request_id']) > 128):
18
+ raise ValueError('request_id must be a non-empty string of at most 128 characters')
19
+ if any(key in spec for key in ('provider', 'executor', 'resolved_runner', 'runtime')):
20
+ raise ValueError('execution configuration and Runner resolution are owned by the server')
21
+ if 'runner' not in spec:
22
+ return # Explicit legacy compatibility: existing adapter specs still reach the API.
23
+ if set(spec) - _FIELDS:
24
+ raise ValueError('fixed-input inference cannot mix legacy or runtime fields')
25
+ runner = spec['runner']
26
+ if (not isinstance(runner, str)
27
+ or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*@[^\s@]+', runner)):
28
+ raise ValueError('runner requires namespace/name@revision')
29
+ if any(not isinstance(spec.get(key), str) or not spec[key].strip()
30
+ for key in ('name', 'serving_profile')):
31
+ raise ValueError('fixed-input inference requires name and serving_profile')
32
+ inputs = spec.get('inputs', {})
33
+ if (not isinstance(inputs, dict) or set(inputs) - {'checkpoint'}
34
+ or 'checkpoint' in inputs and (not isinstance(inputs['checkpoint'], str)
35
+ or not inputs['checkpoint'].startswith('envloop://') or not inputs['checkpoint'][10:])):
36
+ raise ValueError('inputs accepts one Checkpoint ANS URI')