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,519 @@
1
+ """One executor session: hello, optional prepare, start, telemetry, one terminal message.
2
+
3
+ Threading, because it is the part a reviewer has to agree with: the protocol reader blocks on
4
+ stdin, and a handler may block for hours, so the two cannot share a thread. The session reads
5
+ ``hello`` and ``start`` on the main thread, then hands the handler to a worker thread and
6
+ keeps reading, so a ``stop`` that arrives mid-run is seen immediately. The frame writer is
7
+ thread-safe, which is what lets log lines and the terminal message come from either thread.
8
+
9
+ A run ends with exactly one of ``result``, ``canceled`` or ``fault``, after which the process
10
+ exits. Anything the handler raises that is not a :class:`velophex.BusinessError` is an
11
+ ``Application`` fault; a package or handler that will not load is ``Application`` with
12
+ ``INVALID_PACKAGE``, matching the workflow executor so the two runtimes retry alike.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import inspect
19
+ import logging
20
+ import threading
21
+ import traceback
22
+ from dataclasses import replace
23
+ from typing import Any, TextIO
24
+
25
+ from velophex.contracts.executor import (
26
+ CURRENT_VERSION,
27
+ FAULT_ENVELOPE_VERSION,
28
+ MINIMUM_VERSION,
29
+ CanceledMessage,
30
+ ExecutionContextInfo,
31
+ ExecutorCapabilities,
32
+ ExecutorMessage,
33
+ ExecutorProtocolException,
34
+ FaultInfo,
35
+ FaultMessage,
36
+ FaultRetryScopes,
37
+ HelloAckMessage,
38
+ HelloMessage,
39
+ PreparedMessage,
40
+ PrepareMessage,
41
+ RequestStopMessage,
42
+ ResultMessage,
43
+ StartMessage,
44
+ StopMessage,
45
+ SubResultMessage,
46
+ select_version,
47
+ )
48
+ from velophex.contracts.runtimes import (
49
+ ExecutionPhases,
50
+ FaultCodes,
51
+ FaultKinds,
52
+ LogLevels,
53
+ RuntimeKind,
54
+ SignalReasons,
55
+ )
56
+ from velophex.entrypoints import EntryPointDefinition, EntryPointInputError, normalize_outputs
57
+ from velophex.errors import BusinessError, VelophexError
58
+ from velophex.runtime import _session as ambient
59
+ from velophex.worker.framing import FrameReader
60
+ from velophex.worker.loader import LoadedEntryPoint, PackageLoadError, load_entry_point
61
+ from velophex.worker.outbox import ExecutorOutbox, WorkerLogHandler
62
+
63
+ __all__ = [
64
+ "EXIT_OK",
65
+ "EXIT_PROTOCOL_VIOLATION",
66
+ "WORKER_NAME",
67
+ "WorkerSession",
68
+ ]
69
+
70
+ #: The name the worker reports in ``hello-ack``.
71
+ WORKER_NAME = "velophex.worker"
72
+
73
+ EXIT_OK = 0
74
+ EXIT_PROTOCOL_VIOLATION = 2
75
+
76
+ #: How long to wait past the grace period before giving up on a cooperative stop. The parent
77
+ #: kills the process tree at the grace deadline, so this only matters when the parent is slow.
78
+ _STOP_SLACK_SECONDS = 5.0
79
+
80
+
81
+ class WorkerSession:
82
+ """Serves one executor session over an authenticated frame pair."""
83
+
84
+ def __init__(
85
+ self,
86
+ reader: FrameReader,
87
+ outbox: ExecutorOutbox,
88
+ *,
89
+ runtime_version: str,
90
+ worker_version: str,
91
+ diagnostics: TextIO | None = None,
92
+ ) -> None:
93
+ self._reader = reader
94
+ self._outbox = outbox
95
+ self._runtime_version = runtime_version
96
+ self._worker_version = worker_version
97
+ self._diagnostics = diagnostics
98
+ self._session: ambient.RunSession | None = None
99
+ self._loop: asyncio.AbstractEventLoop | None = None
100
+ self._handler_task: asyncio.Task[Any] | None = None
101
+ self._parent_capabilities: frozenset[str] = frozenset()
102
+
103
+ def run(self) -> int:
104
+ """Serve the session and return the process exit code."""
105
+ try:
106
+ if not self._handshake():
107
+ return EXIT_PROTOCOL_VIOLATION
108
+
109
+ while True:
110
+ message = self._reader.read()
111
+
112
+ if message is None:
113
+ return EXIT_OK
114
+
115
+ if isinstance(message, PrepareMessage):
116
+ self._outbox.send(self._prepare(message))
117
+ elif isinstance(message, StartMessage):
118
+ self._send_terminal(self._start(message))
119
+ return EXIT_OK
120
+ elif isinstance(message, StopMessage):
121
+ # Nothing runs yet: the session ends as canceled.
122
+ self._outbox.send(CanceledMessage(reason=message.reason or SignalReasons.USER))
123
+ return EXIT_OK
124
+ else:
125
+ self._note(f"Ignoring unexpected '{message.type_name}' before start.")
126
+ except ExecutorProtocolException as exception:
127
+ self._note(f"Protocol violation ({exception.error}): {exception}")
128
+ return EXIT_PROTOCOL_VIOLATION
129
+
130
+ # ---------------------------------------------------------------- handshake
131
+
132
+ def _handshake(self) -> bool:
133
+ message = self._reader.read()
134
+
135
+ if not isinstance(message, HelloMessage):
136
+ self._note("The first message was not 'hello'.")
137
+ return False
138
+
139
+ version = select_version(message.minimum_version, message.maximum_version)
140
+ self._parent_capabilities = frozenset(message.capabilities)
141
+
142
+ if version is None:
143
+ self._outbox.send(
144
+ FaultMessage(
145
+ FaultInfo(
146
+ kind=FaultKinds.INFRASTRUCTURE,
147
+ code=FaultCodes.EXECUTOR_PROTOCOL_VIOLATION,
148
+ message=(
149
+ f"The parent speaks executor protocol {message.minimum_version}-{message.maximum_version}; "
150
+ f"this worker speaks {MINIMUM_VERSION}-{CURRENT_VERSION}."
151
+ ),
152
+ )
153
+ )
154
+ )
155
+ return False
156
+
157
+ self._outbox.send(
158
+ HelloAckMessage(
159
+ version=version,
160
+ runtime=RuntimeKind.PYTHON,
161
+ runtime_version=self._runtime_version,
162
+ executor_version=self._worker_version,
163
+ # Only should-stop: the Python environment is prepared by the Runner's uv
164
+ # adapter before this process starts (ADR-P008), there is no Python debugger
165
+ # over this protocol yet, and per-element progress has no meaning for a
166
+ # handler call. request-stop raises velophex.should_stop().
167
+ capabilities=[ExecutorCapabilities.SHOULD_STOP],
168
+ )
169
+ )
170
+
171
+ return True
172
+
173
+ # ---------------------------------------------------------------- prepare
174
+
175
+ def _prepare(self, message: PrepareMessage) -> ExecutorMessage:
176
+ """Answer a ``prepare`` a conforming parent should not have sent.
177
+
178
+ The worker does not offer the ``prepare`` capability: by the time it runs, ``uv sync``
179
+ has already built the environment it is running in. Answering ``prepared`` rather than
180
+ faulting keeps a parent that asks anyway from stalling.
181
+ """
182
+ self._note("Ignoring 'prepare': the Python environment is prepared before the worker starts.")
183
+ return PreparedMessage(environment_root=message.environment_root, fingerprint=None)
184
+
185
+ # ---------------------------------------------------------------- start
186
+
187
+ def _start(self, message: StartMessage) -> ExecutorMessage:
188
+ try:
189
+ loaded = load_entry_point(message.package_root, message.entry_point_id)
190
+ except PackageLoadError as error:
191
+ self._outbox.log(LogLevels.ERROR, str(error), ExecutionPhases.ENVIRONMENT_PREPARE)
192
+ return FaultMessage(
193
+ FaultInfo(
194
+ kind=FaultKinds.APPLICATION,
195
+ code=FaultCodes.INVALID_PACKAGE,
196
+ message=str(error),
197
+ retryable=False,
198
+ )
199
+ )
200
+
201
+ try:
202
+ arguments = loaded.definition.bind(message.inputs)
203
+ except EntryPointInputError as error:
204
+ return FaultMessage(
205
+ FaultInfo(
206
+ kind=FaultKinds.APPLICATION,
207
+ code=FaultCodes.INVALID_INPUT,
208
+ message=str(error),
209
+ detail=" ".join(error.pointers),
210
+ retryable=False,
211
+ )
212
+ )
213
+
214
+ return self._invoke(loaded, arguments, message.context)
215
+
216
+ def _invoke(
217
+ self,
218
+ loaded: LoadedEntryPoint,
219
+ arguments: dict[str, Any],
220
+ context: ExecutionContextInfo,
221
+ ) -> ExecutorMessage:
222
+ session = ambient.RunSession.create(context, loaded.definition, loaded.project.name)
223
+ ambient.set_session(session)
224
+ self._session = session
225
+
226
+ if ExecutorCapabilities.SUB_INVOKE in self._parent_capabilities:
227
+ session.enable_sub_invoke(self._outbox.send)
228
+
229
+ handler_log = WorkerLogHandler(self._outbox)
230
+ root = logging.getLogger()
231
+ previous_level = root.level
232
+ root.addHandler(handler_log)
233
+
234
+ if previous_level > logging.INFO or previous_level == logging.NOTSET:
235
+ root.setLevel(logging.INFO)
236
+
237
+ outcome: list[ExecutorMessage] = []
238
+ finished = threading.Event()
239
+
240
+ def body() -> None:
241
+ try:
242
+ outcome.append(self._call(loaded.definition, arguments))
243
+ except BaseException as error:
244
+ outcome.append(self._fault_from(error, session))
245
+ finally:
246
+ finished.set()
247
+
248
+ worker = threading.Thread(target=body, name="velophex-handler", daemon=True)
249
+
250
+ self._outbox.log(LogLevels.INFORMATION, f"Started '{loaded.entry_point.id}'.", ExecutionPhases.EXECUTION)
251
+ worker.start()
252
+
253
+ try:
254
+ self._listen_in_background(finished)
255
+ finished.wait()
256
+ finally:
257
+ root.removeHandler(handler_log)
258
+ root.setLevel(previous_level)
259
+ ambient.clear_session()
260
+ self._session = None
261
+
262
+ if outcome:
263
+ terminal = outcome[0]
264
+ else:
265
+ terminal = FaultMessage(
266
+ FaultInfo(
267
+ kind=FaultKinds.APPLICATION,
268
+ code=FaultCodes.EXECUTOR_CRASHED,
269
+ message="The handler thread ended without an outcome.",
270
+ )
271
+ )
272
+
273
+ # A handler that returned normally after a stop was asked for still ended because of
274
+ # the stop; the job is Canceled, not Succeeded.
275
+ if session.stop_requested and isinstance(terminal, ResultMessage):
276
+ return CanceledMessage(reason=session.stop_reason or SignalReasons.USER)
277
+
278
+ return terminal
279
+
280
+ def _call(self, definition: EntryPointDefinition, arguments: dict[str, Any]) -> ExecutorMessage:
281
+ if definition.is_async:
282
+ outputs = self._call_async(definition, arguments)
283
+ else:
284
+ outputs = definition.function(**arguments)
285
+
286
+ if inspect.isawaitable(outputs):
287
+ # A sync function that returns a coroutine (a wrapped handler) still has to
288
+ # be awaited, or the automation silently does nothing.
289
+ outputs = asyncio.run(outputs)
290
+
291
+ return ResultMessage(outputs=normalize_outputs(outputs))
292
+
293
+ def _call_async(self, definition: EntryPointDefinition, arguments: dict[str, Any]) -> Any:
294
+ async def main() -> Any:
295
+ self._loop = asyncio.get_running_loop()
296
+ self._handler_task = asyncio.ensure_future(definition.function(**arguments))
297
+
298
+ try:
299
+ return await self._handler_task
300
+ finally:
301
+ self._loop = None
302
+ self._handler_task = None
303
+
304
+ return asyncio.run(main())
305
+
306
+ # ---------------------------------------------------------------- control channel
307
+
308
+ def _listen_in_background(self, finished: threading.Event) -> None:
309
+ """Start reading control messages while the handler runs.
310
+
311
+ The reader blocks on stdin, so it needs a thread of its own; it is a daemon because
312
+ the process exits right after the terminal message, with the read still outstanding.
313
+ """
314
+ reader = threading.Thread(target=self._listen_loop, args=(finished,), name="velophex-control", daemon=True)
315
+ reader.start()
316
+
317
+ def _listen_loop(self, finished: threading.Event) -> None:
318
+ while not finished.is_set():
319
+ try:
320
+ message = self._reader.read()
321
+ except ExecutorProtocolException as exception:
322
+ self._note(f"Protocol violation ({exception.error}): {exception}")
323
+ message = None
324
+
325
+ if message is None:
326
+ # The parent closed stdin: it is gone, so the run is stopped and no step will
327
+ # ever be answered.
328
+ self._request_stop(SignalReasons.USER, grace_milliseconds=0)
329
+
330
+ if self._session is not None:
331
+ self._session.fail_pending("The parent closed the executor channel before the step answered.")
332
+
333
+ return
334
+
335
+ if isinstance(message, SubResultMessage):
336
+ session = self._session
337
+
338
+ if session is not None and not session.deliver(message):
339
+ # An unknown or repeated id is a protocol violation (EXECUTOR-PROTOCOL.md 4.1):
340
+ # every waiting call fails rather than trusting the channel any further.
341
+ self._note(f"Protocol violation: sub-result for unknown request {message.request_id!r}.")
342
+ session.fail_pending(f"The parent answered an unknown step request {message.request_id!r}.")
343
+ elif isinstance(message, StopMessage):
344
+ self._outbox.log(LogLevels.INFORMATION, f"Stop requested ({message.reason}).")
345
+ self._request_stop(message.reason, message.grace_period_milliseconds)
346
+ elif isinstance(message, RequestStopMessage):
347
+ # The soft stop: only the flag velophex.should_stop() reads; nothing is canceled.
348
+ session = self._session
349
+
350
+ if session is not None and not session.should_stop:
351
+ self._outbox.log(LogLevels.INFORMATION, "The run was asked to stop when its current work is done.")
352
+ session.request_soft_stop()
353
+ else:
354
+ self._note(f"Ignoring unexpected '{message.type_name}' during a run.")
355
+
356
+ def _request_stop(self, reason: str, grace_milliseconds: int) -> None:
357
+ session = self._session
358
+
359
+ if session is None or session.stop_requested:
360
+ return
361
+
362
+ session.request_stop(reason or SignalReasons.USER)
363
+
364
+ grace = max(grace_milliseconds, 0) / 1000.0
365
+ threading.Timer(grace + _STOP_SLACK_SECONDS, self._grace_expired, args=(grace,)).start()
366
+
367
+ def _grace_expired(self, grace: float) -> None:
368
+ """Cancel what can be cancelled once the grace period has passed.
369
+
370
+ A synchronous handler cannot be interrupted from outside without corrupting whatever
371
+ it holds open, so the platform's answer is the parent's kill. An ``async`` handler is
372
+ different: cancelling its task unwinds through ``finally`` blocks, so the worker does
373
+ that and lets the handler decide how to end.
374
+ """
375
+ session = self._session
376
+
377
+ if session is None or not session.stop_requested:
378
+ return
379
+
380
+ loop, task = self._loop, self._handler_task
381
+
382
+ if loop is not None and task is not None and not task.done():
383
+ self._outbox.log(
384
+ LogLevels.WARNING,
385
+ f"The grace period of {grace:.0f}s expired; cancelling the handler task.",
386
+ )
387
+ loop.call_soon_threadsafe(task.cancel)
388
+ else:
389
+ self._outbox.log(
390
+ LogLevels.WARNING,
391
+ f"The grace period of {grace:.0f}s expired and the handler is still running; "
392
+ "the parent will end this process.",
393
+ )
394
+
395
+ # ---------------------------------------------------------------- faults
396
+
397
+ def _fault_from(self, error: BaseException, session: ambient.RunSession) -> ExecutorMessage:
398
+ if isinstance(error, BusinessError):
399
+ return FaultMessage(
400
+ FaultInfo(
401
+ kind=FaultKinds.BUSINESS,
402
+ code=error.code,
403
+ message=error.message,
404
+ detail=error.detail,
405
+ retryable=False,
406
+ )
407
+ )
408
+
409
+ if isinstance(error, (asyncio.CancelledError, KeyboardInterrupt)) or (
410
+ isinstance(error, SystemExit) and session.stop_requested
411
+ ):
412
+ return CanceledMessage(reason=session.stop_reason or SignalReasons.USER)
413
+
414
+ self._note_exception(error)
415
+
416
+ # An error that knows its code (a Runtime API refusal such as ai_budget_exhausted, an
417
+ # agent guardrail) keeps it, with the envelope: it does not collapse to UNHANDLED_EXCEPTION.
418
+ fault = error.to_fault() if isinstance(error, VelophexError) else None
419
+
420
+ if fault is None:
421
+ fault = FaultInfo(
422
+ kind=FaultKinds.APPLICATION,
423
+ code=FaultCodes.UNHANDLED_EXCEPTION,
424
+ message=f"{type(error).__name__}: {error}",
425
+ # The type and message only: the traceback goes to stderr, which the parent
426
+ # captures and redacts.
427
+ detail=type(error).__module__ + "." + type(error).__qualname__,
428
+ )
429
+
430
+ if getattr(error, "effects_committed", False):
431
+ # The run changed something before it failed (an agent's tool call): running it
432
+ # again would repeat that, so it is never retried automatically.
433
+ fault = replace(
434
+ fault,
435
+ retryable=False,
436
+ version=FAULT_ENVELOPE_VERSION,
437
+ retry_scope=FaultRetryScopes.NONE,
438
+ effects_committed=True,
439
+ )
440
+
441
+ return FaultMessage(fault)
442
+
443
+ # ---------------------------------------------------------------- terminal delivery
444
+
445
+ def _send_terminal(self, message: ExecutorMessage) -> None:
446
+ """Send the one terminal message, or a fault that says why it could not be sent.
447
+
448
+ The handler's return value is only turned into JSON here, after the handler has
449
+ finished. Without this check a ``datetime`` in the outputs, a NaN, or outputs over the
450
+ frame limit raised out of the session with no terminal frame at all - which the parent
451
+ can only report as a retryable ``EXECUTOR_CRASHED``, so a run that will fail the same
452
+ way every time was retried. The replacement fault is ``Application`` and not
453
+ retryable, with a stable code, because re-running the same code on the same inputs
454
+ returns the same value.
455
+ """
456
+ try:
457
+ payload = self._outbox.encode(message)
458
+ except ExecutorProtocolException as error:
459
+ payload = self._outbox.encode(
460
+ _undeliverable(
461
+ message,
462
+ FaultCodes.RESULT_TOO_LARGE,
463
+ f"The '{message.type_name}' message is larger than the "
464
+ f"{self._outbox.maximum_payload_bytes}-byte frame limit ({error}).",
465
+ )
466
+ )
467
+ except (TypeError, ValueError) as error:
468
+ self._note_exception(error)
469
+ payload = self._outbox.encode(
470
+ _undeliverable(
471
+ message,
472
+ FaultCodes.RESULT_NOT_SERIALIZABLE,
473
+ f"The '{message.type_name}' message cannot be written as JSON: {_bounded(str(error))}",
474
+ )
475
+ )
476
+
477
+ self._outbox.send_payload(payload)
478
+
479
+ # ---------------------------------------------------------------- diagnostics
480
+
481
+ def _note(self, message: str) -> None:
482
+ if self._diagnostics is not None:
483
+ print(message, file=self._diagnostics, flush=True)
484
+
485
+ def _note_exception(self, error: BaseException) -> None:
486
+ if self._diagnostics is not None:
487
+ traceback.print_exception(type(error), error, error.__traceback__, file=self._diagnostics)
488
+ self._diagnostics.flush()
489
+
490
+
491
+ _MAXIMUM_REASON_CHARACTERS = 1024
492
+
493
+
494
+ def _bounded(text: str) -> str:
495
+ return text if len(text) <= _MAXIMUM_REASON_CHARACTERS else text[:_MAXIMUM_REASON_CHARACTERS] + "..."
496
+
497
+
498
+ def _undeliverable(message: ExecutorMessage, code: str, reason: str) -> FaultMessage:
499
+ """The fault sent in place of a terminal message that cannot be framed.
500
+
501
+ Built only from strings the worker controls (bounded), so it always encodes and always
502
+ fits. A handler's ``result`` becomes a plain output fault; any other terminal message
503
+ (a business fault whose detail is not a string, say) keeps its code in the detail so the
504
+ original outcome is not lost entirely.
505
+ """
506
+ detail = None
507
+
508
+ if isinstance(message, FaultMessage):
509
+ detail = _bounded(f"Original fault: {message.fault.kind} / {message.fault.code}")
510
+
511
+ return FaultMessage(
512
+ FaultInfo(
513
+ kind=FaultKinds.APPLICATION,
514
+ code=code,
515
+ message=_bounded(reason),
516
+ detail=detail,
517
+ retryable=False,
518
+ )
519
+ )