velophex 0.2.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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,471 @@
1
+ """What a running automation can ask the platform for.
2
+
3
+ Two things live here:
4
+
5
+ * the **ambient run** - :func:`stop_requested`, :func:`stop_reason`, :func:`context` - which
6
+ the worker installs around the handler, and
7
+ * the **Runtime API** - :func:`client` and the typed helpers below - which reaches the
8
+ Orchestrator for assets, secrets, credentials, queues, storage buckets, logs and child jobs.
9
+
10
+ Both need a real run: outside one (a unit test, an import) :func:`context` returns ``None``
11
+ and :func:`client` raises. Nothing here reads the environment for credentials; the execution
12
+ token arrives in the ``start`` context and stays in memory (ADR-P005).
13
+
14
+ >>> from velophex import runtime
15
+ >>>
16
+ >>> api_key = runtime.secret("ErpApiKey")
17
+ >>> while not runtime.stop_requested():
18
+ ... item = runtime.claim_queue_item("Invoices")
19
+ ... if item is None:
20
+ ... break
21
+ >>>
22
+ >>> for file in runtime.list_files("Invoices", "inbox", pattern="*.pdf"):
23
+ ... runtime.download_file("Invoices", file.path, f"C:/work/{file.name}")
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import sys
29
+ from collections import Counter
30
+ from datetime import datetime
31
+ from os import PathLike
32
+ from pathlib import Path
33
+ from typing import Any
34
+ from uuid import UUID
35
+
36
+ from velophex.contracts.executor import ExecutionContextInfo, SubResultMessage
37
+ from velophex.errors import VelophexError
38
+ from velophex.runtime._session import RunSession, clear_session, current_session, set_session
39
+ from velophex.runtime.client import (
40
+ ActionAppNotFoundError,
41
+ ActionHubDisabledError,
42
+ ActionHubError,
43
+ ActionHubRuntimeUnsupportedError,
44
+ ActionTaskCorrelationConflictError,
45
+ ActionTaskInputInvalidError,
46
+ ActionTaskNotFoundError,
47
+ ActionTaskNotOwnedError,
48
+ ActionTaskTimeoutError,
49
+ AgentsNotEnabledError,
50
+ AiBudgetExhaustedError,
51
+ AiError,
52
+ AiPolicyDeniedError,
53
+ AiProviderUnavailableError,
54
+ AiRequestInvalidError,
55
+ RuntimeApiError,
56
+ RuntimeClient,
57
+ StorageBucketsUnsupportedError,
58
+ runtime_base_url,
59
+ )
60
+ from velophex.runtime.models import (
61
+ ActionAppSchema,
62
+ ActionTaskCompleter,
63
+ ActionTaskResult,
64
+ AddQueueItemRequest,
65
+ CancellationState,
66
+ QueueItem,
67
+ ResolvedSecret,
68
+ RuntimeActionTask,
69
+ RuntimeAsset,
70
+ RuntimeContext,
71
+ RuntimeCredential,
72
+ RuntimeJob,
73
+ RuntimeQueue,
74
+ StartedJob,
75
+ StorageFile,
76
+ StorageFileList,
77
+ )
78
+
79
+ __all__ = [
80
+ "ActionAppNotFoundError",
81
+ "ActionAppSchema",
82
+ "ActionHubDisabledError",
83
+ "ActionHubError",
84
+ "ActionHubRuntimeUnsupportedError",
85
+ "ActionTaskCompleter",
86
+ "ActionTaskCorrelationConflictError",
87
+ "ActionTaskInputInvalidError",
88
+ "ActionTaskNotFoundError",
89
+ "ActionTaskNotOwnedError",
90
+ "ActionTaskResult",
91
+ "ActionTaskTimeoutError",
92
+ "AddQueueItemRequest",
93
+ "AgentsNotEnabledError",
94
+ "AiBudgetExhaustedError",
95
+ "AiError",
96
+ "AiPolicyDeniedError",
97
+ "AiProviderUnavailableError",
98
+ "AiRequestInvalidError",
99
+ "CancellationState",
100
+ "QueueItem",
101
+ "ResolvedSecret",
102
+ "RunSession",
103
+ "RuntimeActionTask",
104
+ "RuntimeApiError",
105
+ "RuntimeAsset",
106
+ "RuntimeClient",
107
+ "RuntimeContext",
108
+ "RuntimeCredential",
109
+ "RuntimeJob",
110
+ "RuntimeQueue",
111
+ "StartedJob",
112
+ "StorageBucketsUnsupportedError",
113
+ "StorageFile",
114
+ "StorageFileList",
115
+ "add_queue_item",
116
+ "asset",
117
+ "claim_queue_item",
118
+ "clear_session",
119
+ "client",
120
+ "complete_queue_item",
121
+ "context",
122
+ "create_action_task",
123
+ "credential",
124
+ "current_session",
125
+ "delete_file",
126
+ "download_file",
127
+ "fail_queue_item",
128
+ "get_action_task",
129
+ "invoke_automation",
130
+ "list_files",
131
+ "list_jobs",
132
+ "queue",
133
+ "raise_alert",
134
+ "read_text",
135
+ "runtime_base_url",
136
+ "secret",
137
+ "set_asset",
138
+ "set_session",
139
+ "should_stop",
140
+ "start_job",
141
+ "stop_job",
142
+ "stop_reason",
143
+ "stop_requested",
144
+ "upload_file",
145
+ "wait_for_action_task",
146
+ "write_text",
147
+ ]
148
+
149
+
150
+ def context() -> ExecutionContextInfo | None:
151
+ """The ``start`` context of this run, without the execution token, or ``None`` outside a run."""
152
+ session = current_session()
153
+ return session.context.redacted() if session is not None else None
154
+
155
+
156
+ def stop_requested() -> bool:
157
+ """True once the parent asked this run to end.
158
+
159
+ A long-running automation is expected to poll this between units of work: the platform
160
+ never interrupts Python code on its own, it asks, waits out the grace period and then
161
+ kills the process tree.
162
+ """
163
+ session = current_session()
164
+ return session.stop_requested if session is not None else False
165
+
166
+
167
+ def should_stop() -> bool:
168
+ """True once the parent asked this run to finish its current work and end.
169
+
170
+ An operator's Stop arrives first as ``request-stop``, which only raises this flag, and only
171
+ later, if the run has not ended, as ``stop``; this is true after either. Unlike
172
+ :func:`stop_requested`, a run that ends normally after a ``request-stop`` still succeeds.
173
+ The Python twin of the workflow Should Stop activity (engine ADR 0037 section 6).
174
+ """
175
+ session = current_session()
176
+ return session.should_stop if session is not None else False
177
+
178
+
179
+ def stop_reason() -> str | None:
180
+ """``User`` or ``Timeout`` once a stop was asked for, else ``None``."""
181
+ session = current_session()
182
+ return session.stop_reason if session is not None else None
183
+
184
+
185
+ def client() -> RuntimeClient:
186
+ """The Runtime API client for this attempt."""
187
+ session = current_session()
188
+
189
+ if session is None:
190
+ raise VelophexError("There is no VeloPhex run in this process; the Runtime API is only available to a handler.")
191
+
192
+ return session.client()
193
+
194
+
195
+ def invoke_automation(
196
+ package_id: str,
197
+ inputs: dict[str, Any] | None = None,
198
+ *,
199
+ entry_point: str | None = None,
200
+ timeout_seconds: int | None = None,
201
+ ) -> SubResultMessage:
202
+ """Run another published automation as a step of this job and return its outcome.
203
+
204
+ The Robot runs it on this machine under this job's lease, token and deadline (a hybrid step,
205
+ ADR-P007): it takes no robot slot of its own, so it cannot wait behind the job that asked
206
+ for it. The package must be pinned for this job by the Orchestrator. The result carries
207
+ ``outcome`` (``Succeeded``, ``Failed`` or ``Canceled``), ``outputs`` and the step's ``fault``.
208
+ """
209
+ session = current_session()
210
+
211
+ if session is None:
212
+ raise VelophexError("There is no VeloPhex run in this process; steps are only available to a handler.")
213
+
214
+ return session.invoke(package_id, inputs, entry_point, timeout_seconds)
215
+
216
+
217
+ def asset(name: str) -> RuntimeAsset:
218
+ """Read an asset of the job's workspace."""
219
+ return client().asset(name)
220
+
221
+
222
+ def set_asset(name: str, **value: Any) -> None:
223
+ """Set an asset the job's workspace owns, e.g. ``set_asset("LastRun", text="ok")``."""
224
+ client().set_asset(name, **value)
225
+
226
+
227
+ def secret(name: str) -> str:
228
+ """Resolve a secret asset, or ``"<credential>.password"`` for a credential's password.
229
+
230
+ Returns the plain text, because that is what a caller passes on to the system it logs in
231
+ to. :meth:`RuntimeClient.resolve_secret` returns the :class:`ResolvedSecret` instead, whose
232
+ ``value`` is a ``SecretStr`` that prints as ``**********``.
233
+ """
234
+ return client().resolve_secret(name).get_secret_value()
235
+
236
+
237
+ def credential(name: str) -> RuntimeCredential:
238
+ """The account name of a credential asset; the password comes from :func:`secret`."""
239
+ return client().credential(name)
240
+
241
+
242
+ def queue(name: str) -> RuntimeQueue:
243
+ """Queue metadata by name."""
244
+ return client().queue(name)
245
+
246
+
247
+ def add_queue_item(
248
+ queue_name: str,
249
+ payload: Any | None = None,
250
+ *,
251
+ reference: str | None = None,
252
+ priority: int | None = None,
253
+ deadline: datetime | None = None,
254
+ defer_until: datetime | None = None,
255
+ ) -> QueueItem:
256
+ """Add an item to a queue of the job's workspace."""
257
+ request = AddQueueItemRequest(
258
+ reference=reference,
259
+ priority=priority,
260
+ payload=payload,
261
+ deadline=deadline,
262
+ deferUntil=defer_until,
263
+ )
264
+
265
+ return client().add_queue_item(queue_name, request)
266
+
267
+
268
+ def claim_queue_item(queue_name: str) -> QueueItem | None:
269
+ """Claim the next item of a queue, or ``None`` when nothing is due."""
270
+ return client().claim_queue_item(queue_name)
271
+
272
+
273
+ def complete_queue_item(item_id: UUID | str, output: Any | None = None) -> QueueItem:
274
+ """Mark a claimed item successful."""
275
+ return client().complete_queue_item(item_id, output)
276
+
277
+
278
+ def fail_queue_item(
279
+ item_id: UUID | str,
280
+ *,
281
+ kind: str,
282
+ reason: str | None = None,
283
+ output: Any | None = None,
284
+ ) -> QueueItem:
285
+ """Mark a claimed item failed: ``Business`` is final, ``Application`` is retried."""
286
+ return client().fail_queue_item(item_id, kind=kind, reason=reason, output=output)
287
+
288
+
289
+ def start_job(
290
+ *,
291
+ automation_id: UUID | str | None = None,
292
+ automation_name: str | None = None,
293
+ inputs: Any | None = None,
294
+ priority: int | None = None,
295
+ ) -> StartedJob:
296
+ """Start a child automation in this job's workspace."""
297
+ return client().start_job(
298
+ automation_id=automation_id,
299
+ automation_name=automation_name,
300
+ inputs=inputs,
301
+ priority=priority,
302
+ )
303
+
304
+
305
+ def list_jobs(
306
+ *,
307
+ automation_name: str | None = None,
308
+ statuses: list[str] | None = None,
309
+ top: int = 50,
310
+ ) -> list[RuntimeJob]:
311
+ """The newest jobs of this job's workspace (at most 100)."""
312
+ return client().list_jobs(automation_name=automation_name, statuses=statuses, top=top)
313
+
314
+
315
+ def stop_job(job_id: UUID | str, *, kill: bool = False) -> None:
316
+ """Ask a job of this workspace to stop, or with ``kill`` end it at once."""
317
+ client().stop_job(job_id, kill=kill)
318
+
319
+
320
+ def raise_alert(message: str, *, severity: str = "Info") -> None:
321
+ """Raise an alert (``Info``, ``Warning`` or ``Error``) tied to this job."""
322
+ client().raise_alert(message, severity=severity)
323
+
324
+
325
+ #: Calls per call site in this process, so a loop raises one task per iteration while a retried
326
+ #: attempt, which runs the same code again from the start, replays the same keys.
327
+ _call_sites: Counter[str] = Counter()
328
+
329
+
330
+ def create_action_task(
331
+ app_key: str,
332
+ version: int,
333
+ input: dict[str, Any] | None = None,
334
+ *,
335
+ correlation_key: str | None = None,
336
+ title: str | None = None,
337
+ assignee_user_id: UUID | str | None = None,
338
+ assignee_group: str | None = None,
339
+ due_at: datetime | None = None,
340
+ priority: str | None = None,
341
+ ) -> UUID:
342
+ """Raise an Action Hub task from a published app version and return its id.
343
+
344
+ >>> task_id = runtime.create_action_task("approval", 1, {"title": "Laptop", "amount": 1200})
345
+
346
+ ``correlation_key`` defaults to the call site and how many times it ran in this process, so a
347
+ retried attempt gets the same task back instead of raising a second one (see
348
+ ``docs/action-hub.md``, "Correlation keys").
349
+ """
350
+ if correlation_key is None:
351
+ caller = sys._getframe(1)
352
+ site = f"{caller.f_code.co_filename}:{caller.f_lineno}"
353
+ _call_sites[site] += 1
354
+ correlation_key = f"py:{site.rsplit('/', 1)[-1].rsplit(chr(92), 1)[-1]}#{_call_sites[site]}"
355
+
356
+ return client().create_action_task(
357
+ app_key,
358
+ version,
359
+ input,
360
+ correlation_key=correlation_key,
361
+ title=title,
362
+ assignee_user_id=assignee_user_id,
363
+ assignee_group=assignee_group,
364
+ due_at=due_at,
365
+ priority=priority,
366
+ )
367
+
368
+
369
+ def get_action_task(task_id: UUID | str) -> RuntimeActionTask:
370
+ """Read an action task this job created: status, and outcome and output once completed."""
371
+ return client().get_action_task(task_id)
372
+
373
+
374
+ def wait_for_action_task(
375
+ task_id: UUID | str,
376
+ *,
377
+ timeout: float = 900.0,
378
+ raise_on_timeout: bool = False,
379
+ ) -> ActionTaskResult:
380
+ """Wait in the job for the task's decision; ``timedOut`` (task left open) after ``timeout`` seconds.
381
+
382
+ The default is 15 minutes, and the wait never runs past the job's deadline or a stop request.
383
+ This always waits in the job and holds the Robot: a Python job cannot be checkpointed, so it
384
+ cannot suspend the way the XAML Wait for Action Task does past 60 minutes. For waits of hours
385
+ or days give the app a continuation automation.
386
+ """
387
+ session = current_session()
388
+ deadline = session.context.deadline if session is not None else None
389
+
390
+ return client().wait_for_action_task(
391
+ task_id,
392
+ timeout=timeout,
393
+ raise_on_timeout=raise_on_timeout,
394
+ deadline=deadline,
395
+ stop_requested=stop_requested,
396
+ )
397
+
398
+
399
+ # ---------------------------------------------------------------------------------------------
400
+ # Storage buckets: the Python twins of the Upload File, Download File, List Files, Delete File,
401
+ # Read Text and Write Text activities (velophex.orchestrator.storage.*). A bucket is named; it is
402
+ # the job's workspace's own or one shared into it, and "default" always exists. Paths use "/".
403
+ # ---------------------------------------------------------------------------------------------
404
+
405
+
406
+ def upload_file(
407
+ bucket: str,
408
+ local_path: str | PathLike[str],
409
+ path: str | None = None,
410
+ *,
411
+ content_type: str | None = None,
412
+ ) -> StorageFile:
413
+ """Upload a local file to ``path`` in ``bucket`` (its own name at the root when ``path`` is ``None``).
414
+
415
+ An existing path gets a new version in an internal bucket; the bucket's size limit (at most
416
+ 100 MiB) and quotas apply.
417
+ """
418
+ source = Path(local_path)
419
+
420
+ with source.open("rb") as content:
421
+ return client().upload_file(bucket, path or source.name, content, content_type=content_type)
422
+
423
+
424
+ def download_file(bucket: str, path: str, local_path: str | PathLike[str]) -> Path:
425
+ """Download ``path`` from ``bucket`` to ``local_path`` (folders are created); returns the local path.
426
+
427
+ A failed download leaves no partial file behind.
428
+ """
429
+ target = Path(local_path)
430
+ target.parent.mkdir(parents=True, exist_ok=True)
431
+ partial = target.with_name(target.name + ".partial")
432
+
433
+ try:
434
+ with partial.open("wb") as destination:
435
+ client().download_file(bucket, path, destination)
436
+
437
+ partial.replace(target)
438
+ finally:
439
+ partial.unlink(missing_ok=True)
440
+
441
+ return target
442
+
443
+
444
+ def list_files(
445
+ bucket: str,
446
+ prefix: str | None = None,
447
+ *,
448
+ pattern: str | None = None,
449
+ recursive: bool = False,
450
+ ) -> list[StorageFile]:
451
+ """Every file under ``prefix`` (the root when ``None``), across all pages.
452
+
453
+ ``pattern`` is ``*``/``?`` wildcards on the file name, several separated by ``;``
454
+ (``"*.pdf;*.xlsx"``). ``recursive`` includes every folder below ``prefix``.
455
+ """
456
+ return list(client().iter_files(bucket, prefix, pattern=pattern, recursive=recursive))
457
+
458
+
459
+ def delete_file(bucket: str, path: str) -> None:
460
+ """Delete ``path`` from ``bucket``; :class:`RuntimeApiError` ``storage_file_not_found`` when absent."""
461
+ client().delete_file(bucket, path)
462
+
463
+
464
+ def read_text(bucket: str, path: str, *, encoding: str = "utf-8") -> str:
465
+ """The text of ``path`` in ``bucket``."""
466
+ return client().read_file(bucket, path).decode(encoding)
467
+
468
+
469
+ def write_text(bucket: str, path: str, text: str, *, encoding: str = "utf-8") -> StorageFile:
470
+ """Store ``text`` at ``path`` in ``bucket`` as a ``text/plain`` file in ``encoding``."""
471
+ return client().upload_file(bucket, path, text.encode(encoding), content_type=f"text/plain; charset={encoding}")
@@ -0,0 +1,192 @@
1
+ """The ambient run an automation can ask about: stop requests, context and the API client.
2
+
3
+ The worker installs exactly one :class:`RunSession` for the life of the process and tears it
4
+ down afterwards, so :func:`velophex.runtime.stop_requested` and friends work without the
5
+ handler threading anything through its own call stack. A module-level object rather than a
6
+ :class:`contextvars.ContextVar` is deliberate: one process runs one automation (ADR-P005), and
7
+ a worker thread the handler starts must see the same stop flag as the handler itself.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import threading
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from velophex.contracts.executor import ExecutionContextInfo, SubInvokeMessage, SubResultMessage
19
+ from velophex.entrypoints import EntryPointDefinition
20
+ from velophex.errors import VelophexError
21
+ from velophex.runtime.client import RuntimeClient
22
+
23
+ __all__ = ["RunSession", "clear_session", "current_session", "set_session"]
24
+
25
+
26
+ @dataclass
27
+ class RunSession:
28
+ """One automation run, as the handler may observe it."""
29
+
30
+ context: ExecutionContextInfo
31
+ entry_point: EntryPointDefinition | None
32
+ package_root: str
33
+ _stop: threading.Event
34
+ _stop_reason: str | None = None
35
+ _client: RuntimeClient | None = None
36
+ _should_stop: bool = False
37
+ _send_sub_invoke: Callable[[SubInvokeMessage], None] | None = None
38
+ _pending: dict[str, list[Any]] = field(default_factory=dict)
39
+ _pending_lock: threading.Lock = field(default_factory=threading.Lock)
40
+
41
+ @classmethod
42
+ def create(
43
+ cls,
44
+ context: ExecutionContextInfo,
45
+ entry_point: EntryPointDefinition | None,
46
+ package_root: str,
47
+ ) -> RunSession:
48
+ """Start a session for one ``start`` message."""
49
+ return cls(context=context, entry_point=entry_point, package_root=package_root, _stop=threading.Event())
50
+
51
+ @property
52
+ def stop_requested(self) -> bool:
53
+ """True once the parent asked the run to end."""
54
+ return self._stop.is_set()
55
+
56
+ @property
57
+ def stop_reason(self) -> str | None:
58
+ """``User`` or ``Timeout`` once a stop was asked for."""
59
+ return self._stop_reason
60
+
61
+ def request_stop(self, reason: str) -> None:
62
+ """Record a stop request. Called by the worker when a ``stop`` frame arrives."""
63
+ self._stop_reason = reason
64
+ self._stop.set()
65
+
66
+ @property
67
+ def should_stop(self) -> bool:
68
+ """True once the parent asked the run to finish its work and end, or to end now."""
69
+ return self._should_stop or self._stop.is_set()
70
+
71
+ def request_soft_stop(self) -> None:
72
+ """Record a ``request-stop``: the automation should end on its own.
73
+
74
+ Nothing is canceled, and a run that then returns normally still succeeds (engine ADR
75
+ 0037 section 6).
76
+ """
77
+ self._should_stop = True
78
+
79
+ def wait_for_stop(self, timeout: float | None = None) -> bool:
80
+ """Block until a stop is requested or *timeout* seconds elapse."""
81
+ return self._stop.wait(timeout)
82
+
83
+ def enable_sub_invoke(self, send: Callable[[SubInvokeMessage], None]) -> None:
84
+ """Let the run ask the parent for steps; the worker calls it when the parent serves them."""
85
+ self._send_sub_invoke = send
86
+
87
+ def invoke(
88
+ self,
89
+ package_id: str,
90
+ inputs: Any | None,
91
+ entry_point_id: str | None,
92
+ timeout_seconds: int | None,
93
+ ) -> SubResultMessage:
94
+ """Run a published automation as a step of this job and block until its ``sub-result``.
95
+
96
+ The parent (the Robot) runs it on this machine, under this job's lease, token and
97
+ deadline, without a slot of its own. It always answers, also when the job is stopped
98
+ (``Canceled``), so the wait is not bounded here.
99
+ """
100
+ if self._send_sub_invoke is None:
101
+ raise VelophexError(
102
+ "This run cannot invoke another automation: the parent does not serve sub-invoke "
103
+ "(a local velophex run, or a Robot that predates it)."
104
+ )
105
+
106
+ request_id = uuid.uuid4().hex
107
+ slot: list[Any] = [threading.Event(), None]
108
+
109
+ with self._pending_lock:
110
+ self._pending[request_id] = slot
111
+
112
+ self._send_sub_invoke(SubInvokeMessage(request_id, package_id, entry_point_id, inputs, timeout_seconds))
113
+ slot[0].wait()
114
+
115
+ with self._pending_lock:
116
+ self._pending.pop(request_id, None)
117
+
118
+ if isinstance(slot[1], BaseException):
119
+ raise slot[1]
120
+
121
+ return slot[1]
122
+
123
+ def deliver(self, result: SubResultMessage) -> bool:
124
+ """Hand a ``sub-result`` to the call waiting for it; ``False`` for an id nobody is waiting for."""
125
+ with self._pending_lock:
126
+ slot = self._pending.get(result.request_id)
127
+
128
+ if slot is None or slot[0].is_set():
129
+ return False
130
+
131
+ slot[1] = result
132
+ slot[0].set()
133
+ return True
134
+
135
+ def fail_pending(self, reason: str) -> None:
136
+ """End every waiting step call with an error: the parent is gone or broke the protocol."""
137
+ with self._pending_lock:
138
+ for slot in self._pending.values():
139
+ if not slot[0].is_set():
140
+ slot[1] = VelophexError(reason)
141
+ slot[0].set()
142
+
143
+ def client(self) -> RuntimeClient:
144
+ """The Runtime API client for this attempt, created on first use.
145
+
146
+ Raises :class:`VelophexError` when the run has no Orchestrator behind it, which is
147
+ the case for ``velophex run`` and for Studio's local Run.
148
+ """
149
+ if self._client is None:
150
+ if not self.context.orchestrator_url or not self.context.runtime_token:
151
+ raise VelophexError(
152
+ "This run has no Orchestrator: start context carries no orchestratorUrl or runtimeToken. "
153
+ "Assets, queues and child jobs are only available for jobs dispatched by the Orchestrator."
154
+ )
155
+
156
+ self._client = RuntimeClient(
157
+ self.context.orchestrator_url,
158
+ self.context.runtime_token,
159
+ trace_parent=self.context.trace_parent,
160
+ )
161
+
162
+ return self._client
163
+
164
+ def close(self) -> None:
165
+ """Release the Runtime API connection pool."""
166
+ if self._client is not None:
167
+ self._client.close()
168
+ self._client = None
169
+
170
+
171
+ _current: RunSession | None = None
172
+
173
+
174
+ def set_session(session: RunSession) -> None:
175
+ """Install the ambient session. The worker calls this once per ``start``."""
176
+ global _current
177
+ _current = session
178
+
179
+
180
+ def clear_session() -> None:
181
+ """Remove the ambient session and close its client."""
182
+ global _current
183
+
184
+ if _current is not None:
185
+ _current.close()
186
+
187
+ _current = None
188
+
189
+
190
+ def current_session() -> RunSession | None:
191
+ """The ambient session, or ``None`` outside a run."""
192
+ return _current