velophex 0.2.0__tar.gz → 0.5.3__tar.gz
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.
- velophex-0.5.3/PKG-INFO +680 -0
- velophex-0.5.3/README.md +652 -0
- {velophex-0.2.0 → velophex-0.5.3}/pyproject.toml +14 -4
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/__init__.py +3 -2
- velophex-0.5.3/src/velophex/_cert_store.py +207 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/_json.py +15 -2
- velophex-0.5.3/src/velophex/_redaction.py +60 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/__init__.py +15 -2
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_loop.py +93 -23
- velophex-0.5.3/src/velophex/agents/_prompt.py +80 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_tools.py +78 -8
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/testing.py +81 -4
- velophex-0.5.3/src/velophex/cli/design_time.py +243 -0
- velophex-0.5.3/src/velophex/cli/main.py +1209 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/publish.py +71 -1
- velophex-0.5.3/src/velophex/cli/signin.py +530 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/sync.py +58 -1
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/templates.py +48 -8
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/agents.py +18 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/diagnostics.py +9 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/executor.py +221 -1
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/package.py +63 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/project.py +166 -20
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/runtimes.py +39 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/packaging.py +5 -1
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/runtime/__init__.py +326 -6
- velophex-0.5.3/src/velophex/runtime/_local.py +197 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/runtime/_session.py +44 -11
- velophex-0.5.3/src/velophex/runtime/client.py +1940 -0
- velophex-0.5.3/src/velophex/runtime/data_hub.py +451 -0
- velophex-0.5.3/src/velophex/runtime/models.py +764 -0
- velophex-0.5.3/src/velophex/signing.py +897 -0
- velophex-0.5.3/src/velophex/webhooks.py +111 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/framing.py +17 -3
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/loader.py +3 -3
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/outbox.py +4 -3
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/session.py +136 -23
- velophex-0.2.0/PKG-INFO +0 -313
- velophex-0.2.0/README.md +0 -285
- velophex-0.2.0/src/velophex/cli/main.py +0 -548
- velophex-0.2.0/src/velophex/runtime/client.py +0 -957
- velophex-0.2.0/src/velophex/runtime/models.py +0 -326
- velophex-0.2.0/src/velophex/signing.py +0 -425
- {velophex-0.2.0 → velophex-0.5.3}/.gitignore +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/LICENSE +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/agent-runner/README.md +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_errors.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/agents/_tracing.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/cli/__init__.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/__init__.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/_model.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/signin.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/contracts/validation.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/entrypoints.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/errors.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/py.typed +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/__init__.py +0 -0
- {velophex-0.2.0 → velophex-0.5.3}/src/velophex/worker/__main__.py +0 -0
velophex-0.5.3/PKG-INFO
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: velophex
|
|
3
|
+
Version: 0.5.3
|
|
4
|
+
Summary: VeloPhex Python SDK: author, run, pack and publish Python automations for the VeloPhex platform.
|
|
5
|
+
Project-URL: Homepage, https://velophex.com
|
|
6
|
+
Author: VeloPhex
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: ==3.12.*
|
|
17
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
18
|
+
Requires-Dist: pydantic<3,>=2.9
|
|
19
|
+
Requires-Dist: typer<0.16,>=0.12
|
|
20
|
+
Provides-Extra: agents
|
|
21
|
+
Requires-Dist: jsonschema<5,>=4.23; extra == 'agents'
|
|
22
|
+
Requires-Dist: langchain-core<2,>=1.6.6; extra == 'agents'
|
|
23
|
+
Requires-Dist: langchain-openai<2,>=1.6.7; extra == 'agents'
|
|
24
|
+
Requires-Dist: langgraph<1.3,>=1.2.12; extra == 'agents'
|
|
25
|
+
Provides-Extra: signing
|
|
26
|
+
Requires-Dist: cryptography<51,>=50; extra == 'signing'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# VeloPhex.Python — the `velophex` SDK
|
|
30
|
+
|
|
31
|
+
The Python half of the VeloPhex hybrid platform: the library a Python automation is written
|
|
32
|
+
against, the worker the Runner executes it with, and the CLI that packs and publishes it.
|
|
33
|
+
New to VeloPhex? Start at `docs/START-HERE.md` at the root of the repository.
|
|
34
|
+
|
|
35
|
+
A Python automation is an ordinary [uv](https://docs.astral.sh/uv/) project with a
|
|
36
|
+
`project.json` and one or more functions marked `@velophex.entrypoint`. `velophex pack` turns
|
|
37
|
+
it into the same signed `.nupkg` a Studio workflow produces, so the Orchestrator stores, places
|
|
38
|
+
and starts both the same way.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import velophex
|
|
42
|
+
from velophex import runtime
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@velophex.entrypoint(id="main", display_name="Total an invoice")
|
|
46
|
+
def run(pdf_path: str, pages: int = 1) -> dict[str, float]:
|
|
47
|
+
"""Total one invoice."""
|
|
48
|
+
if not pdf_path.endswith(".pdf"):
|
|
49
|
+
raise velophex.BusinessError("Only PDF invoices are accepted.", code="INVOICE_REJECTED")
|
|
50
|
+
|
|
51
|
+
api_key = runtime.secret("ErpApiKey")
|
|
52
|
+
return {"total": 42.5}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Files go through Orchestrator storage buckets, the twins of the workflow storage activities:
|
|
56
|
+
`runtime.upload_file`, `download_file`, `list_files` (prefix, `*.pdf;*.xlsx` patterns, recursive),
|
|
57
|
+
`delete_file`, `read_text` and `write_text`. A bucket is named and must exist; none is created implicitly.
|
|
58
|
+
|
|
59
|
+
## Verifying this component
|
|
60
|
+
|
|
61
|
+
```powershell
|
|
62
|
+
cd VeloPhex.Python
|
|
63
|
+
uv sync # creates .venv and writes uv.lock (Python 3.12)
|
|
64
|
+
uv run pytest # the whole suite
|
|
65
|
+
uv run ruff check # lint
|
|
66
|
+
uv run ruff format --check # formatting
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`uv` picks the interpreter from `requires-python` (`>=3.12,<3.13`, ADR-P008). The contract
|
|
70
|
+
conformance tests read `VeloPhex.Platform.Contracts` from the sibling folder; if the components
|
|
71
|
+
are checked out apart, point at it:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
$env:VELOPHEX_CONTRACTS_DIR = "<path>\VeloPhex.Platform.Contracts"
|
|
75
|
+
uv run pytest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tests that need it skip with a message rather than failing.
|
|
79
|
+
|
|
80
|
+
## What is where
|
|
81
|
+
|
|
82
|
+
| Path | What it is |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `src/velophex/_json.py` | JSON that is byte-identical to `PlatformJson` in .NET. The escaping, the layout and the number literals are all part of the contract |
|
|
85
|
+
| `src/velophex/entrypoints.py` | `@entrypoint`; derives input/output JSON Schema from type hints with pydantic `TypeAdapter` |
|
|
86
|
+
| `src/velophex/errors.py` | `BusinessError` (a `Business` fault, never retried) and `VelophexError` |
|
|
87
|
+
| `src/velophex/contracts/` | `project.json`, package manifest v2, executor protocol messages, runtime kinds, the lexical rules — mirrors `VeloPhex.Platform.Contracts` |
|
|
88
|
+
| `src/velophex/worker/` | `python -m velophex.worker`: framing, the session state machine, the `logging` bridge and the handler loader |
|
|
89
|
+
| `src/velophex/runtime/` | `stop_requested()`, `context()` and the httpx client for `/api/v1/runtime/*` |
|
|
90
|
+
| `src/velophex/webhooks.py` | `verify()`: checks a webhook delivery's `VeloPhex-Signature` |
|
|
91
|
+
| `src/velophex/packaging.py` | The deterministic `.nupkg` builder, the twin of the engine's `AutomationPackageBuilder` |
|
|
92
|
+
| `src/velophex/cli/` | `velophex init \| run \| pack \| publish \| login \| logout` (typer); `signin.py` is the browser sign-in and the stored session |
|
|
93
|
+
| `src/velophex/agents/` | `velophex.agents.run`: the agent tool-calling loop (extra `velophex[agents]`); `testing.py` has the fake gateway and fake tools |
|
|
94
|
+
| `agent-runner/` | The `Agent.Runner` package every agent runs; see its README for how CI builds and signs it |
|
|
95
|
+
| `tests/data/sample_project/` | A Python project whose `project.json` was written by the **.NET** serializer |
|
|
96
|
+
| `tests/data/golden/` | The package the **.NET** `AutomationPackageBuilder` produced from that project |
|
|
97
|
+
|
|
98
|
+
## Quickstart: your first local run
|
|
99
|
+
|
|
100
|
+
You need [uv](https://docs.astral.sh/uv/) and nothing else; uv fetches CPython 3.12 itself.
|
|
101
|
+
`velophex` is on PyPI, so four commands take you from an empty folder to a run:
|
|
102
|
+
|
|
103
|
+
```powershell
|
|
104
|
+
uvx --python 3.12 velophex init Hello.World
|
|
105
|
+
cd Hello.World
|
|
106
|
+
uv sync
|
|
107
|
+
uv run velophex run main
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The last command logs `INFO hello_world.main: Hello from VeloPhex` and prints
|
|
111
|
+
`{"message": "Hello from VeloPhex"}`. These are the steps `velophex init` prints. The generated
|
|
112
|
+
handler, `src/hello_world/main.py`, takes one input, `message`, with a default. `uv sync` writes
|
|
113
|
+
`uv.lock`, which every package must carry.
|
|
114
|
+
|
|
115
|
+
Inputs go in a file or inline:
|
|
116
|
+
|
|
117
|
+
```powershell
|
|
118
|
+
echo '{"message": "Hi"}' > inputs.json
|
|
119
|
+
uv run velophex run main --inputs-file inputs.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`--inputs '{"message": "Hi"}'` works too where the shell passes the quotes through (PowerShell
|
|
123
|
+
7.3+, bash). Windows PowerShell 5.1 strips them, so use a file there. Exit codes: `0` success,
|
|
124
|
+
`1` failed, `2` unusable inputs, `3` a business rule. An error the SDK expects prints one line;
|
|
125
|
+
`--verbose` adds debug logging and the traceback.
|
|
126
|
+
|
|
127
|
+
PyPI may lag the SDK in this repository: the version PyPI serves is the one `uvx` installs, and
|
|
128
|
+
uploading a new one needs the owner's approval (see "Building and publishing the SDK"). To use
|
|
129
|
+
the SDK from a checkout, see "From a checkout" below.
|
|
130
|
+
|
|
131
|
+
### More entry points
|
|
132
|
+
|
|
133
|
+
Decorate another function in a module beside `main.py`, and run it by its id:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@velophex.entrypoint(id="report")
|
|
137
|
+
def report(month: str) -> dict[str, int]:
|
|
138
|
+
return {"rows": 0}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```powershell
|
|
142
|
+
uv run velophex run report --inputs '{"month": "2026-09"}'
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The first run of an id `project.json` does not have adds it, with its schemas, and says so.
|
|
146
|
+
`velophex pack` does the same for every new handler before it builds.
|
|
147
|
+
|
|
148
|
+
### Local runs: assets and secrets
|
|
149
|
+
|
|
150
|
+
A local run has no Orchestrator, so `runtime.asset`, `runtime.secret` and `runtime.credential`
|
|
151
|
+
have nothing to ask. Give them a file instead:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"ErpBaseUrl": "https://erp.example.com",
|
|
156
|
+
"BatchSize": 50,
|
|
157
|
+
"DryRun": true,
|
|
158
|
+
"ErpApiKey": { "secret": "s3cret" },
|
|
159
|
+
"Erp": { "userName": "robot", "password": "p@ss" }
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```powershell
|
|
164
|
+
uv run velophex run main --assets-file local-assets.json
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A string is a Text asset, a number an Integer, `true`/`false` a Boolean, `{"secret": ...}` a
|
|
168
|
+
Secret, and `{"userName": ..., "password": ...}` a Credential. `runtime.secret("Erp.password")`
|
|
169
|
+
reads a credential's password, as it does on a Robot. `init` adds `local-assets.json` to
|
|
170
|
+
`.gitignore`, because it holds secrets. Queues, storage buckets, Data Hub and child jobs still
|
|
171
|
+
need an Orchestrator and fail with a one-line error.
|
|
172
|
+
|
|
173
|
+
### Local runs against an Orchestrator
|
|
174
|
+
|
|
175
|
+
`--orchestrator` makes the local run a design-time run of a real Orchestrator, the Python twin of
|
|
176
|
+
Studio's F5. Nothing is published: the handler runs here and reaches assets, queues, storage and
|
|
177
|
+
Data Hub through the Runtime API, acting as you in the workspace.
|
|
178
|
+
|
|
179
|
+
```powershell
|
|
180
|
+
uv run velophex login --orchestrator https://orchestrator.example.com # once; see "Signing in"
|
|
181
|
+
uv run velophex run main --orchestrator https://orchestrator.example.com --workspace Finance
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`--tenant` and `--workspace` default to `VELOPHEX_TENANT_ID` and `VELOPHEX_WORKSPACE`, then to the
|
|
185
|
+
tenant approved at `velophex login`; the workspace is an id or a name, and may be left out when you
|
|
186
|
+
can start jobs in only one. The token comes from `VELOPHEX_API_KEY` or `--api-key-stdin` when given,
|
|
187
|
+
else from the `velophex login` session, never an option. You need `jobs.create` in the
|
|
188
|
+
workspace and the tenant's design-time runs on (Settings > Security). The run ends with its outcome
|
|
189
|
+
(Succeeded, Failed, or Canceled on Ctrl+C) and is then visible as a design-time job, recorded with
|
|
190
|
+
the `python` runtime (Orchestrators with Contracts 0.8.73 or later; older ones record a workflow run).
|
|
191
|
+
|
|
192
|
+
### From a checkout
|
|
193
|
+
|
|
194
|
+
Contributors, or anyone ahead of PyPI, build the wheel and point uv at it:
|
|
195
|
+
|
|
196
|
+
```powershell
|
|
197
|
+
<checkout>\VeloPhex.Python\build\Build.ps1 # -> VeloPhex.Python\artifacts\dist
|
|
198
|
+
$env:UV_FIND_LINKS = "<checkout>\VeloPhex.Python\artifacts\dist"
|
|
199
|
+
uvx --python 3.12 --from velophex velophex init Hello.World
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`init` finds the SDK wheel through `UV_FIND_LINKS` and copies it into `wheels/`. The project
|
|
203
|
+
then restores on a Robot with no package index at all (see "How a Robot gets the SDK"). From
|
|
204
|
+
PyPI, `init` has no wheel file to copy. It warns that a Robot restores the SDK from its package
|
|
205
|
+
index (`Python:IndexUrl`, PyPI by default), and that is fine for any Robot that can reach one.
|
|
206
|
+
|
|
207
|
+
## Signing in
|
|
208
|
+
|
|
209
|
+
The intended setup is `pip install velophex` (or `uv tool install velophex`), then one sign-in:
|
|
210
|
+
|
|
211
|
+
```powershell
|
|
212
|
+
velophex login --orchestrator https://staging.velophex.com --tenant velo-test/default
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The system browser opens the Orchestrator's approval page; approve it and the CLI is signed in.
|
|
216
|
+
`publish` and `run --orchestrator` then need no URL, tenant or token. `--orchestrator` also takes
|
|
217
|
+
a tenant page pasted from the browser (`https://staging.velophex.com/velo-test/default/orchestrator_`),
|
|
218
|
+
which names the tenant too. `--tenant` (an id, a name, a slug or `org/tenant`) only preselects the
|
|
219
|
+
tenant on the approval page; the session is bound to the one you approve, and `login` prints it.
|
|
220
|
+
After the first time, `velophex login` with no arguments signs in again to the same Orchestrator
|
|
221
|
+
and tenant. `--no-browser` prints the approval page's address instead of opening it.
|
|
222
|
+
|
|
223
|
+
| | |
|
|
224
|
+
|---|---|
|
|
225
|
+
| Flow | OAuth 2.0 Authorization Code + PKCE (S256) on a loopback redirect, client `velophex-cli`, the same as Studio and the Assistant (ADR-P014). The browser returns to `http://127.0.0.1:{48620-48639}/callback` within 5 minutes. |
|
|
226
|
+
| Session | A `vpx_pat_…` access token (the SDK token preset, bound to the approved tenant) renewed with a rotating refresh token; the sign-in lasts 30 days. |
|
|
227
|
+
| Storage | One file per user, one session per Orchestrator: `%LOCALAPPDATA%\VeloPhex\cli\sessions.dat` encrypted with DPAPI for the current Windows user; `~/.config/velophex/cli/sessions.dat` (mode 0600) elsewhere. Never printed. |
|
|
228
|
+
| Precedence | `--api-key-stdin` or `VELOPHEX_API_KEY` (CI), then the stored session (renewed within a minute of its expiry), else an error that says to run `velophex login`. |
|
|
229
|
+
|
|
230
|
+
`velophex logout [--orchestrator URL]` revokes the sign-in on the Orchestrator and deletes it here;
|
|
231
|
+
without `--orchestrator` it signs out of the last Orchestrator signed in to.
|
|
232
|
+
|
|
233
|
+
## Publishing to an Orchestrator
|
|
234
|
+
|
|
235
|
+
From the project folder, signed in (`velophex login`) with `packages.create` in the tenant:
|
|
236
|
+
|
|
237
|
+
```powershell
|
|
238
|
+
uv run velophex pack # -> artifacts\Hello.World.1.0.0.nupkg
|
|
239
|
+
$env:VELOPHEX_SIGNING_PASSWORD = "..." # or --sign-password-stdin, or be prompted
|
|
240
|
+
uv run velophex publish artifacts\Hello.World.1.0.0.nupkg --sign signer.pfx
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
In CI, where nobody can approve a browser sign-in, use a personal access token instead:
|
|
244
|
+
`$env:VELOPHEX_API_KEY = "vpx_pat_..."` (or `--api-key-stdin`) with `--orchestrator` and
|
|
245
|
+
`--tenant <tenantId>` (or `VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`).
|
|
246
|
+
|
|
247
|
+
#### Signing
|
|
248
|
+
|
|
249
|
+
A production Orchestrator ships with `Packages:RequireSignedPackages` on and refuses an unsigned
|
|
250
|
+
package, or one whose signer it does not trust. `pack` and `publish` both sign, with either:
|
|
251
|
+
|
|
252
|
+
| Option | Signs with |
|
|
253
|
+
|---|---|
|
|
254
|
+
| `--sign signer.pfx` | A PKCS#12 file. Its password comes from `VELOPHEX_SIGNING_PASSWORD`, stdin (`--sign-password-stdin`) or a hidden prompt, never from an option. |
|
|
255
|
+
| `--sign-thumbprint <thumbprint>` | A certificate in the Windows `CurrentUser\My` store, by SHA-256 or SHA-1 thumbprint. Windows signs with its key (CNG), so a non-exportable key works, such as the one Studio creates. |
|
|
256
|
+
|
|
257
|
+
Signing needs the `signing` extra (`uv add "velophex[signing]"`). The signature is the detached
|
|
258
|
+
`velophex/signature.json` Studio writes (ADR-P003, ADR-P012). Signing prints the signer's SHA-256
|
|
259
|
+
thumbprint. `publish --sign` signs the package in place; a package already signed by that
|
|
260
|
+
certificate is published as it is, and one signed by another certificate is refused.
|
|
261
|
+
|
|
262
|
+
Before uploading, `publish` reads `GET /api/v1/tenants/{tenantId}/package-signing` (`packages.view`)
|
|
263
|
+
and stops, without uploading, when the tenant would refuse the package:
|
|
264
|
+
|
|
265
|
+
| Situation | `publish` says |
|
|
266
|
+
|---|---|
|
|
267
|
+
| Signing required, package unsigned | This tenant only accepts signed packages. Sign it with --sign <file.pfx> or --sign-thumbprint <thumbprint>, then publish again. |
|
|
268
|
+
| Signed by a certificate the tenant does not trust | This tenant doesn't trust the signing certificate. An admin adds its pin in the Orchestrator under Settings > Package feeds: `spki-sha256:…` |
|
|
269
|
+
|
|
270
|
+
An Orchestrator without the endpoint, or one `publish` cannot read it from, skips the check, and
|
|
271
|
+
the upload's own refusal decides. A tenant trusts the deployment's signers
|
|
272
|
+
(`Packages:TrustedSignerThumbprints`) and its own `trustedSignerPins` (ADR-P025). Robots refuse a
|
|
273
|
+
package the same way unless its signer is in their trusted signers (`configure --trusted-signers`,
|
|
274
|
+
`VeloPhex.Robot/docs/deployment/ROBOT-DEPLOYMENT.md`) or the machine accepts tenant signers.
|
|
275
|
+
Signers are pinned by thumbprint or public key, never by certificate authority. On an evaluation
|
|
276
|
+
stack with signing off, no signing option is needed.
|
|
277
|
+
|
|
278
|
+
The tenant id is the GUID in the tenant's feed URL (`/api/v1/tenants/<tenantId>/feed/v3/index.json`,
|
|
279
|
+
`VeloPhex.Orchestrator/docs/package-feed.md`).
|
|
280
|
+
|
|
281
|
+
### How a Robot gets the SDK
|
|
282
|
+
|
|
283
|
+
A Robot restores the package's environment with `uv sync --frozen`, which installs from exactly
|
|
284
|
+
the sources `uv.lock` records and ignores any index or `--find-links` given at sync time. A lock
|
|
285
|
+
made against a wheel *folder* records that folder's absolute path, and it only restores where
|
|
286
|
+
the folder exists. So `init` **vendors** the SDK instead: it copies this SDK's own wheel into
|
|
287
|
+
the project's `wheels/` folder (from `--sdk-wheel`, or found in the `UV_FIND_LINKS` folders) and
|
|
288
|
+
writes
|
|
289
|
+
|
|
290
|
+
```toml
|
|
291
|
+
[tool.uv.sources]
|
|
292
|
+
velophex = { path = "wheels/velophex-<version>-py3-none-any.whl" }
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`uv.lock` then records the wheel by a path relative to the project and by its SHA-256, and
|
|
296
|
+
`velophex pack` carries `wheels/` in the package like any other project file (hashed into the
|
|
297
|
+
manifest and signed). The Robot's `uv sync --frozen` restores the SDK from the unpacked package
|
|
298
|
+
itself, with no index and no Robot configuration, and it never asks any index for a package
|
|
299
|
+
named `velophex` - which is what closes the dependency-confusion gap (PY-02). The SDK's own dependencies (pydantic, httpx, typer) come from the Robot's index
|
|
300
|
+
(`Python:IndexUrl`, PyPI by default) or, when it reaches none, from the wheelhouse the Robot's
|
|
301
|
+
Full installer ships. That wheelhouse holds exactly the versions this SDK's `uv.lock` pins, so
|
|
302
|
+
`init` also writes them as `[tool.uv] constraint-dependencies`, and `package = false` so the
|
|
303
|
+
project is never built (the worker puts `src/` on `sys.path` itself) and needs no build backend.
|
|
304
|
+
|
|
305
|
+
Commit `wheels/` with the project. To move a project to a newer SDK, replace the wheel, update
|
|
306
|
+
the path in `pyproject.toml` and run `uv lock`. When `init` finds no wheel it falls back to a
|
|
307
|
+
plain `velophex` dependency and warns that a Robot cannot restore the project until the SDK is
|
|
308
|
+
on an index it can reach.
|
|
309
|
+
|
|
310
|
+
Why not ship the wheel with the Robot instead: under `--frozen` the lock's recorded path wins,
|
|
311
|
+
so a wheel in the Robot's payload would only be used if every developer machine had it at the
|
|
312
|
+
same absolute path. Verified with uv 0.12.15; vendoring needs no Robot change at all.
|
|
313
|
+
|
|
314
|
+
Every other dependency must ship a wheel too. A Robot restores with `uv sync --no-build`, because
|
|
315
|
+
building a package from source runs that package's own build code as the Robot service, so a
|
|
316
|
+
locked dependency with only an sdist fails the restore on the Robot, naming it. `velophex pack`
|
|
317
|
+
reads `uv.lock` and warns about each such package before you publish: one whose lock entry has an
|
|
318
|
+
sdist but no `py3-none-any`, `cp312-*-win_amd64` or `abi3` `win_amd64` wheel. Lock a version that
|
|
319
|
+
ships a wheel, or vendor the wheel in `wheels/` as above. The escape hatch is per machine, not per
|
|
320
|
+
package: `Python:AllowSourceBuilds` in the Robot's configuration (default `false`) lets that Robot
|
|
321
|
+
build from source; turn it on only where every index the Robot reaches is trusted
|
|
322
|
+
(`VeloPhex.Robot/docs/deployment/ROBOT-DEPLOYMENT.md`, "Wheel-only restores").
|
|
323
|
+
|
|
324
|
+
## Building and publishing the SDK
|
|
325
|
+
|
|
326
|
+
```powershell
|
|
327
|
+
build\Build.ps1 # wheel + sdist into artifacts\dist, built twice and compared byte for byte, plus SHA256SUMS
|
|
328
|
+
build\Publish.ps1 # dry run: checks SHA256SUMS and runs `uv publish --dry-run`; uploads nothing
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The version has one source, `__version__` in `src/velophex/__init__.py`; hatchling reads it.
|
|
332
|
+
Publishing is an outward action that needs the owner's approval: `Publish.ps1 -Publish`
|
|
333
|
+
uploads (`velophex` is on PyPI since 0.2.0), and refuses a public index if `pyproject.toml`
|
|
334
|
+
carries the `Private :: Do Not Upload` classifier (PyPI rejects that classifier too). Credentials come
|
|
335
|
+
from `UV_PUBLISH_TOKEN` or trusted publishing, never from a parameter.
|
|
336
|
+
|
|
337
|
+
The token is never a command-line option (it would land in shell history and the process
|
|
338
|
+
list); `--api-key` is refused with an error that says where to put it instead.
|
|
339
|
+
|
|
340
|
+
`pack` refreshes `project.json` from the decorated handlers before it builds (`--no-sync` to
|
|
341
|
+
skip), requires `uv.lock`, writes the manifest with the SHA-512 of every content file, and
|
|
342
|
+
computes `requiredRuntimes` as this project's runtime plus every `automationDependencies`
|
|
343
|
+
runtime — which is what the Orchestrator matches against a runner's reported runtimes.
|
|
344
|
+
|
|
345
|
+
`publish` is a `PUT {publicBaseUrl}/api/v1/tenants/{tenantId}/feed/v3/package` with the
|
|
346
|
+
package as multipart and `X-NuGet-ApiKey: vpx_pat_…`. A `409` is success only when the
|
|
347
|
+
feed answers `package_binary_duplicate` with a `sha256` equal to the local package's: these
|
|
348
|
+
exact bytes are already published. Any other `409` - different bytes under a published version -
|
|
349
|
+
fails, naming the package and version: a published version never changes, so increment the
|
|
350
|
+
version in `project.json` and pack again. Without a path, `publish` takes the one package in
|
|
351
|
+
`--output` (`artifacts`) and refuses to guess when there are several. The URL and tenant come from
|
|
352
|
+
the options, then `VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`, then the `velophex login`
|
|
353
|
+
session; the token from `VELOPHEX_API_KEY` or stdin (`--api-key-stdin`), then the session.
|
|
354
|
+
|
|
355
|
+
## How the worker runs an automation
|
|
356
|
+
|
|
357
|
+
The Runner prepares the environment with `uv sync --frozen --no-dev` and then starts
|
|
358
|
+
`<env>\Scripts\python.exe -m velophex.worker` (ADR-P008). Parent and worker speak the executor
|
|
359
|
+
protocol (ADR-P005) over the process's stdin and stdout:
|
|
360
|
+
|
|
361
|
+
1. The worker takes the session key out of `VELOPHEX_EXECUTOR_SESSION_KEY` and **removes the
|
|
362
|
+
variable**, before any automation code is imported. It ignores `VELOPHEX_EXECUTOR_FEED_CREDENTIALS`
|
|
363
|
+
(executor protocol §1): a Python run restores no NuGet packages, and the Runner never sets it for one.
|
|
364
|
+
2. The frame pipe moves off standard output to a private, non-inheritable descriptor, and fd 1,
|
|
365
|
+
the Win32 standard output handle and `sys.stdout` all point at `stderr`. A `print()`, a child
|
|
366
|
+
process (`os.system`, `subprocess`) or a `WriteFile` on the standard handle therefore cannot
|
|
367
|
+
corrupt the frame stream, and a detached grandchild cannot keep the pipe open after the worker exits.
|
|
368
|
+
The parent's pipe moves off standard input the same way, and fd 0, the Win32 standard input
|
|
369
|
+
handle and `sys.stdin` become the null device: an automation or a child process that reads
|
|
370
|
+
standard input gets end-of-file instead of consuming the parent's frames.
|
|
371
|
+
3. `hello` → `hello-ack` (runtime `python`, capabilities `should-stop` and `heartbeat`: the
|
|
372
|
+
environment is already prepared, and there is no Python debugger over this protocol yet).
|
|
373
|
+
From then until the terminal message a `heartbeat` goes out every 15 seconds from a thread of
|
|
374
|
+
its own (0.2.2, contracts 0.8.50); a parent ends a worker it has not heard from for 60 seconds.
|
|
375
|
+
4. `start` names a package root and an entry point id; `project.json` turns the id into
|
|
376
|
+
`module:function`, the module is imported and the inputs are validated against the
|
|
377
|
+
handler's signature before it is called.
|
|
378
|
+
5. While it runs, `logging` records become `log` messages and the parent's `stop` sets
|
|
379
|
+
`velophex.runtime.stop_requested()`.
|
|
380
|
+
6. Exactly one terminal message: `result`, `canceled` or `fault`.
|
|
381
|
+
|
|
382
|
+
Fault mapping, which decides retries:
|
|
383
|
+
|
|
384
|
+
| Raised | Fault |
|
|
385
|
+
|---|---|
|
|
386
|
+
| `velophex.BusinessError` | `Business` / the error's `code`, never retried |
|
|
387
|
+
| inputs that do not fit the handler | `Application` / `INVALID_INPUT`, not retried |
|
|
388
|
+
| a package or handler that will not load | `Application` / `INVALID_PACKAGE` |
|
|
389
|
+
| anything else | `Application` / `UNHANDLED_EXCEPTION` |
|
|
390
|
+
| a return value with no JSON form (`datetime`, a set, NaN) | `Application` / `RESULT_NOT_SERIALIZABLE`, not retried |
|
|
391
|
+
| outputs over the frame limit (16 MiB) | `Application` / `RESULT_TOO_LARGE`, not retried |
|
|
392
|
+
|
|
393
|
+
The last two are checked before the terminal frame is written, so the parent always gets a
|
|
394
|
+
terminal message rather than a crash it would have to report (and retry) as
|
|
395
|
+
`EXECUTOR_CRASHED`. Both are entries in the shared `FaultCodes` table (Contracts 0.1.0-preview.4), so the workflow executor uses the same spellings.
|
|
396
|
+
|
|
397
|
+
A stop is cooperative: the flag is set and the handler is expected to notice. Just before the
|
|
398
|
+
grace period ends (a second before, or halfway through a grace under two seconds), an `async`
|
|
399
|
+
handler's task is cancelled, so it unwinds through `finally` before the parent kills the
|
|
400
|
+
process tree at the deadline; a grace of 0 means "no grace" and cancels it at once. A
|
|
401
|
+
synchronous handler cannot be interrupted safely from outside, so the parent's kill is the
|
|
402
|
+
answer, and the worker logs a warning saying so. A handler that returns *after* a stop was
|
|
403
|
+
asked for ends the run as `canceled`, not `result`, with the reason `Timeout` for a timeout
|
|
404
|
+
stop and `User` for every other.
|
|
405
|
+
|
|
406
|
+
What the worker does when the channel itself goes wrong (0.2.2, contracts 0.8.50):
|
|
407
|
+
|
|
408
|
+
- A frame the parent sends during the run that fails the protocol ends the run: the terminal
|
|
409
|
+
message is `Infrastructure` / `EXECUTOR_PROTOCOL_VIOLATION` and the exit code is 2, as for the
|
|
410
|
+
workflow executor.
|
|
411
|
+
- A `sub-result` for a request nobody waits for (one that timed out, or a repeated answer) is
|
|
412
|
+
logged at Warning and ignored.
|
|
413
|
+
- `invoke_automation` raises in the caller when the request cannot be sent (a value with no JSON
|
|
414
|
+
form, a parent that is gone), and gives up after `timeout_seconds` plus 60 seconds.
|
|
415
|
+
- A frame write that fails breaks the writer: nothing more is written on that channel.
|
|
416
|
+
|
|
417
|
+
Runtime API calls that `POST` carry an `Idempotency-Key` and are sent again, with the same key,
|
|
418
|
+
after a dropped connection or a 502/503/504, so a retried queue completion or child job start
|
|
419
|
+
has one effect.
|
|
420
|
+
|
|
421
|
+
## Flow messages
|
|
422
|
+
|
|
423
|
+
`velophex.runtime.send_flow_message(name, correlation_key=None, variables=None, *, token)`
|
|
424
|
+
continues a flow from Python (0.2.1): it resolves every flow waiting for the message, starts the
|
|
425
|
+
flows that begin with it, or buffers it for `flows.messageTtl`. It calls
|
|
426
|
+
`POST /api/v1/tenants/{t}/workspaces/{ws}/flows/messages` (`VeloPhex.Orchestrator/docs/flows/message-api.md`)
|
|
427
|
+
in the job's tenant and workspace.
|
|
428
|
+
|
|
429
|
+
```python
|
|
430
|
+
from velophex import runtime
|
|
431
|
+
|
|
432
|
+
result = runtime.send_flow_message("invoice-paid", "INV-1042", {"amount": 1250}, token=runtime.secret("FlowsToken"))
|
|
433
|
+
result.outcome # "correlated", "started" or "buffered"
|
|
434
|
+
result.instance_ids # the flow instances it resolved or started
|
|
435
|
+
result.buffered_until # when a buffered message expires
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
- `token` is an access token with `pulse.message` (a personal access token or an application
|
|
439
|
+
token), kept in a secret asset. The job's execution token cannot send flow messages.
|
|
440
|
+
- The `Idempotency-Key` defaults to the job id and the call site, so a retried attempt delivers
|
|
441
|
+
nothing twice; pass `idempotency_key=` to choose your own.
|
|
442
|
+
- A refusal raises `RuntimeApiError` with the problem's `code` (`too_many_matches` for more
|
|
443
|
+
than 100 matches: send the message with a correlation key).
|
|
444
|
+
- Outside a run, `velophex.runtime.client.send_flow_message(orchestrator_url, token, tenant_id,
|
|
445
|
+
workspace_id, name, idempotency_key=...)` takes everything explicitly.
|
|
446
|
+
|
|
447
|
+
## Data Hub
|
|
448
|
+
|
|
449
|
+
Typed business records of the job's workspace (0.3.0, runtime capability `data-hub/1`;
|
|
450
|
+
`VeloPhex.Orchestrator/docs/data-hub.md`). An entity is addressed by its API name and its
|
|
451
|
+
fields by theirs, so an automation runs unchanged in another workspace with the same entity.
|
|
452
|
+
The tested quickstart (an `Invoice` entity, `velophex run --orchestrator`, the expected output) and
|
|
453
|
+
the filter, value-type, limit and error references are in that page.
|
|
454
|
+
|
|
455
|
+
```python
|
|
456
|
+
from decimal import Decimal
|
|
457
|
+
|
|
458
|
+
from velophex import runtime
|
|
459
|
+
from velophex.runtime import where
|
|
460
|
+
|
|
461
|
+
result = runtime.upsert_record("Invoice", "invoiceNumber", {"invoiceNumber": "INV-23", "amount": Decimal("1204.50")})
|
|
462
|
+
result.created # True the first time, False after
|
|
463
|
+
|
|
464
|
+
for invoice in runtime.iter_records("Invoice", where("amount").gte(Decimal("1000")), sort="amount desc"):
|
|
465
|
+
print(invoice["invoiceNumber"], invoice["amount"]) # amount is a Decimal
|
|
466
|
+
|
|
467
|
+
runtime.update_record("Invoice", result.record.id, {"status": "paid"}, revision=result.record.revision)
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
| Call | Does |
|
|
471
|
+
|---|---|
|
|
472
|
+
| `list_entities()`, `entity(name)`, `entity_json_schema(name)` | The workspace's entities, one entity's fields, the JSON Schema of a record |
|
|
473
|
+
| `query_records(entity, filter, *, match, sort, select, page_size, cursor, include_total)` | One page (`next_cursor` for the next); `page_size` up to 1,000 |
|
|
474
|
+
| `iter_records(entity, filter, *, ..., limit=10_000)` | Every match, page after page; `DataHubResultTooLargeError` past `limit` or 64 MiB, `limit=None` lifts it |
|
|
475
|
+
| `get_record(entity, id)` | One record |
|
|
476
|
+
| `create_record(entity, data, *, idempotency_key=None)` | A new record; the same key and data within 24 hours return the first one |
|
|
477
|
+
| `upsert_record(entity, key_field, data)` | Create, or update the record with that unique key; `.created` says which |
|
|
478
|
+
| `update_record(entity, id, values, *, revision=None)` | Only the fields given change; `None` clears one; `revision` refuses a stale write |
|
|
479
|
+
| `delete_record(entity, id)` | Only where the entity's Settings allow automations to delete |
|
|
480
|
+
|
|
481
|
+
- **Filters** are `where("field").op(value)` combined with `&` and `|` (`eq ne in_ not_in gt gte lt
|
|
482
|
+
lte contains starts_with is_null is_not_null`), a dict AST, or the JSON the web app's "Copy filter as
|
|
483
|
+
JSON" gives. `match={"status": "open"}` adds equalities. Text compares case-insensitively.
|
|
484
|
+
- **Sort** is the one string every surface uses: `"amount desc, invoiceNumber"`. Only indexed and
|
|
485
|
+
system fields sort.
|
|
486
|
+
- **Values** are typed from the entity's schema: decimal `Decimal` (sent as a string, never a float),
|
|
487
|
+
date `date`, datetime an aware UTC `datetime` (a naive one is refused), number `int`. A record's
|
|
488
|
+
system fields are attributes (`id`, `revision`, `created_at`, ...); its values are `record["name"]`.
|
|
489
|
+
- **Refusals** are `DataHubError` subclasses by code, each with `cause`, `fix` and `docs_url`:
|
|
490
|
+
`EntityNotFoundError`, `RecordNotFoundError`, `RecordValidationError` (`.errors` by `data.<field>`),
|
|
491
|
+
`UniqueViolationError` (`.field`), `RevisionConflictError`, `SchemaChangedError`,
|
|
492
|
+
`FilterInvalidError` (`.pointer`), `CursorInvalidError`, `FieldNotIndexedError`, `QueryTooExpensiveError`,
|
|
493
|
+
`KeyFieldNotUniqueError`, `IdempotencyKeyReusedError`, `DataHubLimitReachedError`,
|
|
494
|
+
`DeleteDisabledError`, `DataHubAccessDeniedError`, `DataHubDisabledError` (the tenant has it
|
|
495
|
+
off) and `DataHubUnsupportedError` (the Orchestrator predates it).
|
|
496
|
+
- A filter on a field that is not indexed works on small entities and raises a `DataHubWarning`
|
|
497
|
+
(also logged); above 50,000 records it is refused with `FieldNotIndexedError`. Index the field.
|
|
498
|
+
|
|
499
|
+
## Integration connections
|
|
500
|
+
|
|
501
|
+
Vendor credentials the Orchestrator holds for the workspace (0.4.0, runtime capability
|
|
502
|
+
`integration-connections/1`, ADR-P026). The code names a connection by its binding, a connector
|
|
503
|
+
key and the connection's name, and declares it in `project.json`; the package never carries a
|
|
504
|
+
connection id, so the same package uses each workspace's own `Sales` connection.
|
|
505
|
+
|
|
506
|
+
```json
|
|
507
|
+
"integrationConnections": [{ "connector": "salesforce", "name": "Sales" }]
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
import httpx
|
|
512
|
+
|
|
513
|
+
from velophex import runtime
|
|
514
|
+
|
|
515
|
+
sales = runtime.connection("salesforce", "Sales")
|
|
516
|
+
limits = httpx.get(f"{sales.instance_url}/services/data/v61.0/limits", headers=sales.auth_headers())
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
| Call | Does |
|
|
520
|
+
|---|---|
|
|
521
|
+
| `connection(connector, name)` | A handle: `.token`, `.instance_url`, `.auth_headers()`, `.auth_kind`; it leases again at 80% of each lease's lifetime |
|
|
522
|
+
| `lease(connector, name)` | The raw lease (`IntegrationConnectionLease`): at most 15 minutes, never past the job's attempt |
|
|
523
|
+
|
|
524
|
+
- **Secrets.** An OAuth or JWT connection's lease is a freshly minted access token. An api-key,
|
|
525
|
+
basic or custom-header connection's lease is the stored secret itself, which cannot be revoked
|
|
526
|
+
once released. Every leased secret is masked in what the worker writes; keep it out of outputs.
|
|
527
|
+
- **Where a name resolves.** A job searches its own workspace's shared connections; an attended or
|
|
528
|
+
Studio run then also searches the personal workspace of the user it runs for.
|
|
529
|
+
- **Refusals** are `IntegrationConnectionError` subclasses by code, each with `cause`, `fix` and
|
|
530
|
+
`docs_url`: `IntegrationConnectionNotFoundError` (`.searched_workspaces`),
|
|
531
|
+
`IntegrationConnectionBrokenError` (`.reason`, `.fix_url`), `IntegrationConnectionDisabledError`,
|
|
532
|
+
`IntegrationConnectionNotAllowedError` (the connection is for server-side use only),
|
|
533
|
+
`IntegrationsDisabledError` (the tenant has Integrations off) and
|
|
534
|
+
`IntegrationConnectionsUnavailableError` (the Orchestrator does not offer it).
|
|
535
|
+
- **`velophex pack`** warns about a literal `connection("...", "...")` or `lease(...)` in the code that
|
|
536
|
+
`project.json` does not declare.
|
|
537
|
+
- **Local runs** read `"connections"` from `--assets-file`, by connector and then name (a string is
|
|
538
|
+
an access token, an object has the lease's members), or the variable
|
|
539
|
+
`VELOPHEX_CONNECTION_<CONNECTOR>_<NAME>_TOKEN` (upper case, every other character `_`):
|
|
540
|
+
|
|
541
|
+
```json
|
|
542
|
+
{ "connections": { "salesforce": { "Sales": { "accessToken": "00D...", "instanceUrl": "https://acme.my.salesforce.com" } } } }
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### Starting from an event
|
|
546
|
+
|
|
547
|
+
An entry point a trigger starts gets the event (0.5.0, contracts 0.8.78, ADR-P028):
|
|
548
|
+
|
|
549
|
+
- **Standard inputs.** Declare any of `VeloPhexEventConnector`, `VeloPhexEvent`,
|
|
550
|
+
`VeloPhexEventObjectType`, `VeloPhexEventObjectId` and `VeloPhexEventPayload` (the payload as
|
|
551
|
+
JSON text) as `str` parameters, optional with a default. They are filled from the event and a
|
|
552
|
+
trigger mapping cannot target them.
|
|
553
|
+
- **`runtime.context_event()`** returns the `EventContext` (`connector`, `source`, `event`,
|
|
554
|
+
`object_type`, `object_id`, `payload`, `occurred_at`, `trigger_id`, `sample`), or `None` on a
|
|
555
|
+
manual start. An event over 256 KiB keeps only `{"velophextruncated": true, "velophexrecordid": ...}`
|
|
556
|
+
as its payload (`event.truncated`); fetch the record by that id.
|
|
557
|
+
- **Locally**, `velophex run main --event event.json` runs with a sample event: the standard inputs
|
|
558
|
+
the entry point declares are filled from it, and `context_event()` returns it with `sample` set.
|
|
559
|
+
With `--orchestrator` the sample is also sent with the design-time run.
|
|
560
|
+
|
|
561
|
+
```json
|
|
562
|
+
{ "connector": "http", "source": "http-poll", "event": "record.changed", "objectId": "ord_1001", "payload": { "id": "ord_1001", "status": "open" } }
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
## Priority
|
|
566
|
+
|
|
567
|
+
Jobs and queue items share the Orchestrator's 0–100 priority scale, higher first (0.2.3).
|
|
568
|
+
`runtime.add_queue_item(..., priority=)` and `runtime.start_job(..., priority=)` take a
|
|
569
|
+
`runtime.Priority` band or any `int`; the band is sent as its number.
|
|
570
|
+
|
|
571
|
+
```python
|
|
572
|
+
from velophex import runtime
|
|
573
|
+
|
|
574
|
+
runtime.add_queue_item("Invoices", {"invoice": "INV-1"}, priority=runtime.Priority.HIGH) # 75
|
|
575
|
+
runtime.start_job(automation_name="Reconcile", priority=90)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
`Priority` is `LOW` 25, `NORMAL` 50, `HIGH` 75 and `CRITICAL` 100, the Orchestrator's
|
|
579
|
+
`PriorityBands` (a test reads the C# table). `QueueItem.priority_band` and
|
|
580
|
+
`RuntimeJob.priority_band` name the band when the number is one, otherwise `None`.
|
|
581
|
+
|
|
582
|
+
## Verifying a webhook
|
|
583
|
+
|
|
584
|
+
`velophex.webhooks.verify(body, headers, secret, tolerance=300)` checks a delivery's
|
|
585
|
+
`VeloPhex-Signature` exactly as the Orchestrator signs it (`VeloPhex.Orchestrator/docs/webhooks.md`)
|
|
586
|
+
and returns the signed time in Unix seconds (0.2.3). It needs no run, so any receiver can use it.
|
|
587
|
+
|
|
588
|
+
```python
|
|
589
|
+
from velophex import webhooks
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@app.post("/hooks/velophex")
|
|
593
|
+
async def receive(request: Request):
|
|
594
|
+
try:
|
|
595
|
+
webhooks.verify(await request.body(), request.headers, secret)
|
|
596
|
+
except webhooks.WebhookSignatureError:
|
|
597
|
+
return Response(status_code=401)
|
|
598
|
+
...
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
- Pass the raw body before any JSON parsing; re-serialised JSON does not match.
|
|
602
|
+
- `headers` is the request's headers (any case) or the header's value. A header with several
|
|
603
|
+
`v1` values (a secret rotation) verifies when one matches.
|
|
604
|
+
- A timestamp more than `tolerance` seconds from this clock raises, so a captured delivery cannot
|
|
605
|
+
be replayed later. To act exactly once, also remember the CloudEvent's `id`.
|
|
606
|
+
- `tests/test_webhooks.py` and the Orchestrator's `WebhookSignatureVectorTests` pin the same
|
|
607
|
+
vector: change one and you must change the other.
|
|
608
|
+
|
|
609
|
+
## What "the same package as .NET" means
|
|
610
|
+
|
|
611
|
+
`tests/test_pack_parity.py` compares what `velophex pack` builds against
|
|
612
|
+
`tests/data/golden/Sample.Automation.1.2.0.nupkg`, which the engine's
|
|
613
|
+
`AutomationPackageBuilder` produced from `tests/data/sample_project`. Parity is asserted on
|
|
614
|
+
the archive's *content*: the same entry names in the same order, the same fixed
|
|
615
|
+
`2000-01-01T00:00:00` timestamp, and the same bytes in every entry — `content/project.json`,
|
|
616
|
+
`velophex/package-manifest.json`, the `.nuspec` and `[Content_Types].xml`.
|
|
617
|
+
|
|
618
|
+
The *compressed* bytes are deliberately not compared. A .NET `ZipArchive` and Python's `zlib`
|
|
619
|
+
do not emit the same deflate stream, and nothing in the platform depends on it: what is hashed
|
|
620
|
+
and signed is the manifest and the files the manifest lists, never the archive's compression.
|
|
621
|
+
|
|
622
|
+
Regenerate the golden only for an intentional packaging change, by running the .NET builder
|
|
623
|
+
over the same fixture directory.
|
|
624
|
+
|
|
625
|
+
## The JSON, and why it is not `json.dumps`
|
|
626
|
+
|
|
627
|
+
The package manifest carries the SHA-512 of `content/project.json`, so a manifest written here
|
|
628
|
+
and a manifest written by Studio only verify against each other when both writers agree byte
|
|
629
|
+
for byte. `System.Text.Json` and Python's `json` do not agree, in three ways that all matter
|
|
630
|
+
and are all pinned by tests:
|
|
631
|
+
|
|
632
|
+
* The default .NET encoder escapes `"`, `&`, `'`, `+`, `<`, `>`, `` ` `` and everything
|
|
633
|
+
outside printable ASCII as `\uXXXX` with **upper-case** hex. So `">=3.12,<3.13"` is written
|
|
634
|
+
`">=3.12,<3.13"`.
|
|
635
|
+
* A `JsonElement` — an entry point's JSON Schema, say — is re-emitted with its **original
|
|
636
|
+
number literals**: `1.0` stays `1.0`, `1e2` stays `1e2`.
|
|
637
|
+
* A `DateTimeOffset` goes through a typed fast path that never reaches the encoder, so the
|
|
638
|
+
`+` of its offset stays a `+` where the same text in a plain string property would become
|
|
639
|
+
`+`. The executor frame vectors pin exactly this.
|
|
640
|
+
|
|
641
|
+
## Agents: `velophex[agents]`
|
|
642
|
+
|
|
643
|
+
`velophex.agents.run(definition, inputs)` runs an agent definition (`agent-definition` v1,
|
|
644
|
+
`VeloPhex.Platform.Contracts/schemas/agent-definition.v1.schema.json`) and returns its validated
|
|
645
|
+
output. The extra brings LangGraph, `langchain-openai` and `jsonschema` from PyPI; LangGraph is
|
|
646
|
+
pinned to one minor and only `velophex/agents/_loop.py` imports it.
|
|
647
|
+
|
|
648
|
+
* **Model calls** go to the Orchestrator's LLM gateway, `POST <runtime>/ai/chat/completions`, in
|
|
649
|
+
the OpenAI chat completions shape, non-streaming, with the job token and the agent's binding
|
|
650
|
+
(`agent:default`) as the model. Outside a job, `VELOPHEX_AI_BASE_URL`, `VELOPHEX_AI_API_KEY`
|
|
651
|
+
and `VELOPHEX_AI_MODEL` name any OpenAI-compatible endpoint; nothing is traced then.
|
|
652
|
+
* **Tools:** `process` runs a published automation **in this job** (`velophex.runtime.invoke_automation`,
|
|
653
|
+
executor `sub-invoke`: same machine, same lease, no extra robot slot); `mcp`, `http`,
|
|
654
|
+
`vector_search` and `action_task` go through the Runtime API. Every call carries an
|
|
655
|
+
idempotency key, and tool and retrieved content reaches the model fenced as untrusted data.
|
|
656
|
+
* **Approvals** (`approval: before_call`) are bounded waits in the job: at most 900 s, and never
|
|
657
|
+
past the run's own timeout. A rejected or expired approval means the call is not made.
|
|
658
|
+
* **Limits:** 12 model calls, 50 000 tokens and 15 minutes per run, temperature 0.2, unless the
|
|
659
|
+
definition says otherwise. A limit ends the run with `agent_limit_exceeded`; an answer that
|
|
660
|
+
fails the output schema gets one repair turn, then `agent_output_invalid`.
|
|
661
|
+
* **Faults keep their code.** A gateway refusal raises `AiBudgetExhaustedError`,
|
|
662
|
+
`AiPolicyDeniedError`, `AiProviderUnavailableError` or `AiRequestInvalidError` (all
|
|
663
|
+
`RuntimeApiError`), and the worker reports it under that code with the error envelope (cause,
|
|
664
|
+
fix, request id, retry scope, docs URL), not as `UNHANDLED_EXCEPTION`. A run that fails after a
|
|
665
|
+
tool call succeeded reports `effectsCommitted` and is never retried automatically; an unknown
|
|
666
|
+
tool outcome is `tool_outcome_unknown`, reconciled by a person.
|
|
667
|
+
* **Testing:** `velophex.agents.testing.FakeChatServer` (a scripted gateway on 127.0.0.1),
|
|
668
|
+
`FakeTools` and `RecordingTracer`; `tests/test_agents.py` shows the patterns.
|
|
669
|
+
|
|
670
|
+
`velophex run` and the worker shape outputs with the same rule (`normalize_outputs`): `None` is
|
|
671
|
+
`{}`, a scalar or list is `{"value": ...}`, and a value with no JSON form is
|
|
672
|
+
`RESULT_NOT_SERIALIZABLE` in both.
|
|
673
|
+
|
|
674
|
+
## Contract changes
|
|
675
|
+
|
|
676
|
+
`project.json`, the package manifest, the runner protocol and the executor protocol are
|
|
677
|
+
platform contracts. They change only through the `velophex-contract-change` skill, which
|
|
678
|
+
updates the engine, Studio, the Activities, the Orchestrator, the Runner and this SDK together.
|
|
679
|
+
Nothing in `src/velophex/contracts/` may drift on its own; the conformance tests against
|
|
680
|
+
`VeloPhex.Platform.Contracts/schemas/` are there to make drift fail loudly.
|