pegasus-workflows-sdk 0.1.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.
- pegasus_workflows/__init__.py +113 -0
- pegasus_workflows/api.py +529 -0
- pegasus_workflows/cli/__init__.py +45 -0
- pegasus_workflows/cli/init.py +93 -0
- pegasus_workflows/cli/integration_config.py +278 -0
- pegasus_workflows/cli/package.py +108 -0
- pegasus_workflows/cli/push.py +97 -0
- pegasus_workflows/cli/run.py +131 -0
- pegasus_workflows/cli/test.py +186 -0
- pegasus_workflows/manifest.py +229 -0
- pegasus_workflows/templates/README.md +41 -0
- pegasus_workflows/templates/__WORKFLOW_NAME__/__init__.py +1 -0
- pegasus_workflows/templates/__WORKFLOW_NAME__/workflow.py +36 -0
- pegasus_workflows/templates/pegasus-workflows.toml +18 -0
- pegasus_workflows/templates/pyproject.toml +13 -0
- pegasus_workflows_sdk-0.1.0.dist-info/METADATA +224 -0
- pegasus_workflows_sdk-0.1.0.dist-info/RECORD +19 -0
- pegasus_workflows_sdk-0.1.0.dist-info/WHEEL +4 -0
- pegasus_workflows_sdk-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Pegasus Workflows SDK.
|
|
2
|
+
|
|
3
|
+
Authoring surface for Pegasus workflows. Re-exports the Temporal authoring
|
|
4
|
+
primitives (``workflow`` and ``activity``) so workflow authors only ever
|
|
5
|
+
import from :mod:`pegasus_workflows`, and adds the :func:`pegasus_workflow`
|
|
6
|
+
decorator which stashes ``(name, version)`` metadata used by the CLI's
|
|
7
|
+
``package`` step and the ``pegasus-workflows.toml`` manifest.
|
|
8
|
+
|
|
9
|
+
Example
|
|
10
|
+
-------
|
|
11
|
+
.. code-block:: python
|
|
12
|
+
|
|
13
|
+
from pegasus_workflows import pegasus_workflow, workflow
|
|
14
|
+
|
|
15
|
+
@pegasus_workflow(name="send_quote_followup", version="0.1.0")
|
|
16
|
+
class SendQuoteFollowup:
|
|
17
|
+
@workflow.run
|
|
18
|
+
async def run(self, quote_id: str) -> str:
|
|
19
|
+
return f"followed up on {quote_id}"
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
26
|
+
|
|
27
|
+
from temporalio import activity, workflow
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .api import PegasusApiError, PegasusClient
|
|
31
|
+
from .manifest import Manifest, ManifestError, load_manifest
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"activity",
|
|
35
|
+
"workflow",
|
|
36
|
+
"pegasus_workflow",
|
|
37
|
+
"PegasusClient",
|
|
38
|
+
"PegasusApiError",
|
|
39
|
+
"Manifest",
|
|
40
|
+
"ManifestError",
|
|
41
|
+
"load_manifest",
|
|
42
|
+
"WORKFLOW_META_ATTR",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# api.py pulls in httpx (and transitively urllib), which Temporal's workflow
|
|
46
|
+
# sandbox restricts. Workflow files import the authoring primitives from this
|
|
47
|
+
# package, so importing the package must NOT eagerly import api/manifest —
|
|
48
|
+
# otherwise httpx lands in the sandboxed workflow module graph and validation
|
|
49
|
+
# fails. PegasusClient/Manifest/etc. are therefore exposed lazily and only
|
|
50
|
+
# resolved when actually referenced (i.e. inside activities or the CLI).
|
|
51
|
+
_LAZY_EXPORTS = {
|
|
52
|
+
"PegasusClient": "api",
|
|
53
|
+
"PegasusApiError": "api",
|
|
54
|
+
"Manifest": "manifest",
|
|
55
|
+
"ManifestError": "manifest",
|
|
56
|
+
"load_manifest": "manifest",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def __getattr__(name: str) -> Any:
|
|
61
|
+
"""Lazily resolve API/manifest exports (PEP 562 module __getattr__)."""
|
|
62
|
+
module_name = _LAZY_EXPORTS.get(name)
|
|
63
|
+
if module_name is None:
|
|
64
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
65
|
+
import importlib
|
|
66
|
+
|
|
67
|
+
module = importlib.import_module(f".{module_name}", __name__)
|
|
68
|
+
return getattr(module, name)
|
|
69
|
+
|
|
70
|
+
#: Attribute name under which :func:`pegasus_workflow` stores its metadata
|
|
71
|
+
#: dict on the decorated class.
|
|
72
|
+
WORKFLOW_META_ATTR = "__pegasus_workflow__"
|
|
73
|
+
|
|
74
|
+
_T = TypeVar("_T")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pegasus_workflow(
|
|
78
|
+
*, name: str, version: str, description: str | None = None
|
|
79
|
+
) -> Callable[[_T], _T]:
|
|
80
|
+
"""Mark a class as a Pegasus workflow.
|
|
81
|
+
|
|
82
|
+
Wraps :func:`temporalio.workflow.defn` so the class is a valid Temporal
|
|
83
|
+
workflow definition, and records ``(name, version, description)`` on the
|
|
84
|
+
class under :data:`WORKFLOW_META_ATTR`. The CLI reads this metadata for
|
|
85
|
+
introspection; the authoritative manifest still lives in
|
|
86
|
+
``pegasus-workflows.toml``.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
name: Workflow name. Must match ``^[a-z0-9][a-z0-9_-]{0,63}$``.
|
|
90
|
+
version: Semantic version, e.g. ``1.2.3`` or ``1.2.3-beta.1``.
|
|
91
|
+
description: Optional human-readable description.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A decorator that returns the (Temporal-registered) class unchanged.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def decorator(cls: _T) -> _T:
|
|
98
|
+
# Register under the PEGASUS name, not the Python class name: the
|
|
99
|
+
# API starts executions with `client.workflow.start(workflow.name,
|
|
100
|
+
# ...)` (apps/api/src/lib/start-workflow-execution.ts), so the
|
|
101
|
+
# Temporal workflow *type* must equal the manifest name or the
|
|
102
|
+
# worker rejects every task with "Workflow class <name> is not
|
|
103
|
+
# registered". (Caught live in the Phase 3 staging smoke — the
|
|
104
|
+
# class-name registration had been latent since Phase 1.)
|
|
105
|
+
defined: Any = workflow.defn(name=name)(cls) # type: ignore[arg-type]
|
|
106
|
+
setattr(
|
|
107
|
+
defined,
|
|
108
|
+
WORKFLOW_META_ATTR,
|
|
109
|
+
{"name": name, "version": version, "description": description},
|
|
110
|
+
)
|
|
111
|
+
return defined # type: ignore[return-value]
|
|
112
|
+
|
|
113
|
+
return decorator
|
pegasus_workflows/api.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""Hand-rolled REST client for the Pegasus public API.
|
|
2
|
+
|
|
3
|
+
The OpenAPI document at ``/openapi.json`` does not yet cover the workflows
|
|
4
|
+
surface, so this client is written by hand against the contract in
|
|
5
|
+
``apps/api/src/handlers/workflows.ts``.
|
|
6
|
+
|
|
7
|
+
Two responsibilities:
|
|
8
|
+
|
|
9
|
+
1. The workflow publish flow — ``request_upload_url`` → ``upload_artifact``
|
|
10
|
+
(raw S3 PUT) → ``finalize`` — plus ``list_workflows``, ``get_workflow``,
|
|
11
|
+
and ``get_download_url``.
|
|
12
|
+
2. Thin read helpers (``list_customers`` etc.) for use *inside* workflow
|
|
13
|
+
activities, where a workflow needs to read Pegasus domain data.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
__all__ = ["PegasusApiError", "PegasusClient", "MAX_ARTIFACT_BYTES", "ARTIFACT_MIME_TYPE"]
|
|
24
|
+
|
|
25
|
+
#: Maximum artifact size accepted by ``POST /upload-url`` (mirrors the server).
|
|
26
|
+
MAX_ARTIFACT_BYTES = 25 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
#: Content-Type the presigned PUT is signed for. Must match exactly.
|
|
29
|
+
ARTIFACT_MIME_TYPE = "application/zip"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PegasusApiError(Exception):
|
|
34
|
+
"""Raised when the Pegasus API returns a non-2xx response.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
status_code: HTTP status code.
|
|
38
|
+
code: Machine-readable ``code`` from the error body, if present.
|
|
39
|
+
message: Human-readable ``error`` from the error body, if present.
|
|
40
|
+
correlation_id: ``correlationId`` from the body (5xx responses only).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
status_code: int
|
|
44
|
+
code: str | None
|
|
45
|
+
message: str | None
|
|
46
|
+
correlation_id: str | None = None
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str: # noqa: D105 - dataclass repr is enough context
|
|
49
|
+
parts = [f"HTTP {self.status_code}"]
|
|
50
|
+
if self.code:
|
|
51
|
+
parts.append(self.code)
|
|
52
|
+
if self.message:
|
|
53
|
+
parts.append(self.message)
|
|
54
|
+
if self.correlation_id:
|
|
55
|
+
parts.append(f"correlationId={self.correlation_id}")
|
|
56
|
+
return " — ".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
60
|
+
"""Raise :class:`PegasusApiError` if *response* is not 2xx."""
|
|
61
|
+
if response.is_success:
|
|
62
|
+
return
|
|
63
|
+
code: str | None = None
|
|
64
|
+
message: str | None = None
|
|
65
|
+
correlation_id: str | None = None
|
|
66
|
+
try:
|
|
67
|
+
body = response.json()
|
|
68
|
+
if isinstance(body, dict):
|
|
69
|
+
code = body.get("code")
|
|
70
|
+
message = body.get("error")
|
|
71
|
+
correlation_id = body.get("correlationId")
|
|
72
|
+
except ValueError:
|
|
73
|
+
message = response.text or None
|
|
74
|
+
raise PegasusApiError(
|
|
75
|
+
status_code=response.status_code,
|
|
76
|
+
code=code,
|
|
77
|
+
message=message,
|
|
78
|
+
correlation_id=correlation_id,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PegasusClient:
|
|
83
|
+
"""Authenticated client for the Pegasus public API.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
base_url: API origin, e.g. ``http://localhost:3000``.
|
|
87
|
+
token: A ``vnd_`` API key whose service account holds the
|
|
88
|
+
``workflow_developer`` role.
|
|
89
|
+
timeout: Per-request timeout in seconds.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
base_url: str,
|
|
95
|
+
token: str,
|
|
96
|
+
*,
|
|
97
|
+
timeout: float = 30.0,
|
|
98
|
+
transport: httpx.BaseTransport | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
if not token:
|
|
101
|
+
raise ValueError("a Pegasus API token is required")
|
|
102
|
+
self._base_url = base_url.rstrip("/")
|
|
103
|
+
self._token = token
|
|
104
|
+
self._timeout = timeout
|
|
105
|
+
# Optional transport override — used by tests to mock HTTP traffic.
|
|
106
|
+
self._transport = transport
|
|
107
|
+
|
|
108
|
+
# -- internals ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def _client(self) -> httpx.Client:
|
|
111
|
+
return httpx.Client(
|
|
112
|
+
base_url=self._base_url,
|
|
113
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
114
|
+
timeout=self._timeout,
|
|
115
|
+
transport=self._transport,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _bare_client(self) -> httpx.Client:
|
|
119
|
+
"""An httpx client with no base URL — for absolute S3 URLs."""
|
|
120
|
+
return httpx.Client(timeout=self._timeout, transport=self._transport)
|
|
121
|
+
|
|
122
|
+
def _get_json(self, path: str, **params: Any) -> Any:
|
|
123
|
+
with self._client() as client:
|
|
124
|
+
response = client.get(path, params=params or None)
|
|
125
|
+
_raise_for_status(response)
|
|
126
|
+
return response.json()
|
|
127
|
+
|
|
128
|
+
# -- workflow publish flow ---------------------------------------------
|
|
129
|
+
|
|
130
|
+
def request_upload_url(self, name: str, version: str, size_bytes: int) -> dict[str, Any]:
|
|
131
|
+
"""Request a presigned S3 PUT URL for a workflow artifact.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
name: Workflow name.
|
|
135
|
+
version: Workflow version.
|
|
136
|
+
size_bytes: Exact byte size of the zip about to be uploaded.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The ``data`` object: ``{workflowId, uploadUrl, expiresInSeconds}``.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
PegasusApiError: On 409 (duplicate) or any other non-2xx.
|
|
143
|
+
ValueError: If *size_bytes* exceeds :data:`MAX_ARTIFACT_BYTES`.
|
|
144
|
+
"""
|
|
145
|
+
if size_bytes <= 0 or size_bytes > MAX_ARTIFACT_BYTES:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"sizeBytes must be 1..{MAX_ARTIFACT_BYTES} (got {size_bytes})"
|
|
148
|
+
)
|
|
149
|
+
with self._client() as client:
|
|
150
|
+
response = client.post(
|
|
151
|
+
"/api/v1/workflows/upload-url",
|
|
152
|
+
json={"name": name, "version": version, "sizeBytes": size_bytes},
|
|
153
|
+
)
|
|
154
|
+
_raise_for_status(response)
|
|
155
|
+
return response.json()["data"]
|
|
156
|
+
|
|
157
|
+
def upload_artifact(self, upload_url: str, artifact: bytes) -> None:
|
|
158
|
+
"""PUT a workflow artifact to its presigned S3 URL.
|
|
159
|
+
|
|
160
|
+
The ``Content-Type`` and ``Content-Length`` are signed into the URL
|
|
161
|
+
by the server, so they must be sent verbatim or S3 rejects the PUT.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
upload_url: The presigned URL from :meth:`request_upload_url`.
|
|
165
|
+
artifact: The raw zip bytes.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
PegasusApiError: If S3 rejects the upload.
|
|
169
|
+
"""
|
|
170
|
+
with self._bare_client() as client:
|
|
171
|
+
response = client.put(
|
|
172
|
+
upload_url,
|
|
173
|
+
content=artifact,
|
|
174
|
+
headers={
|
|
175
|
+
"Content-Type": ARTIFACT_MIME_TYPE,
|
|
176
|
+
"Content-Length": str(len(artifact)),
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
if not response.is_success:
|
|
180
|
+
raise PegasusApiError(
|
|
181
|
+
status_code=response.status_code,
|
|
182
|
+
code="S3_UPLOAD_FAILED",
|
|
183
|
+
message=response.text or "S3 rejected the artifact upload",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def finalize(self, workflow_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
"""Finalize an upload, creating the ``Workflow`` row.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
workflow_id: The id returned by :meth:`request_upload_url`.
|
|
191
|
+
manifest: The manifest dict (camelCase, see
|
|
192
|
+
:meth:`Manifest.to_api_manifest`).
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The created ``WorkflowResponse`` object.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
PegasusApiError: On 409 (duplicate) or any other non-2xx.
|
|
199
|
+
"""
|
|
200
|
+
with self._client() as client:
|
|
201
|
+
response = client.post(
|
|
202
|
+
"/api/v1/workflows",
|
|
203
|
+
json={"workflowId": workflow_id, "manifest": manifest},
|
|
204
|
+
)
|
|
205
|
+
_raise_for_status(response)
|
|
206
|
+
return response.json()["data"]
|
|
207
|
+
|
|
208
|
+
def run_workflow(
|
|
209
|
+
self,
|
|
210
|
+
workflow_id: str,
|
|
211
|
+
input: dict[str, Any] | None = None,
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Start a server-side execution of *workflow_id*.
|
|
214
|
+
|
|
215
|
+
Triggers ``POST /api/v1/workflows/{id}/run``. The workflow must be
|
|
216
|
+
in the curated executable allowlist (Phase 2: only stdlib workflows
|
|
217
|
+
run server-side) — non-curated names raise with code
|
|
218
|
+
``WORKFLOW_NOT_EXECUTABLE``.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
workflow_id: The workflow to run (GLOBAL or a TENANT fork of one).
|
|
222
|
+
input: JSON-shaped input dict the worker passes to the workflow.
|
|
223
|
+
Defaults to an empty dict.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The freshly-created ``WorkflowExecutionResponse`` object in its
|
|
227
|
+
initial state (``QUEUED`` or ``RUNNING`` depending on whether
|
|
228
|
+
the Temporal start round-trip has completed).
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
PegasusApiError: On 400 (not in allowlist), 404, 502 (Temporal
|
|
232
|
+
start failed), or any other non-2xx.
|
|
233
|
+
"""
|
|
234
|
+
payload = {"input": input or {}}
|
|
235
|
+
with self._client() as client:
|
|
236
|
+
response = client.post(
|
|
237
|
+
f"/api/v1/workflows/{workflow_id}/run",
|
|
238
|
+
json=payload,
|
|
239
|
+
)
|
|
240
|
+
_raise_for_status(response)
|
|
241
|
+
return response.json()["data"]
|
|
242
|
+
|
|
243
|
+
def list_executions(
|
|
244
|
+
self,
|
|
245
|
+
workflow_id: str,
|
|
246
|
+
*,
|
|
247
|
+
limit: int = 50,
|
|
248
|
+
before: str | None = None,
|
|
249
|
+
) -> list[dict[str, Any]]:
|
|
250
|
+
"""List recent executions of *workflow_id*, newest first.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
workflow_id: The workflow whose executions to list.
|
|
254
|
+
limit: Page size (1..200; default 50).
|
|
255
|
+
before: Optional ``execution_id`` of the last row on the
|
|
256
|
+
previous page — keyset pagination, robust to inserts.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
A list of ``WorkflowExecutionResponse`` objects.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
PegasusApiError: On 404 (workflow not visible) or any other
|
|
263
|
+
non-2xx.
|
|
264
|
+
"""
|
|
265
|
+
params: dict[str, Any] = {"limit": limit}
|
|
266
|
+
if before:
|
|
267
|
+
params["before"] = before
|
|
268
|
+
return self._get_json(
|
|
269
|
+
f"/api/v1/workflows/{workflow_id}/executions", **params
|
|
270
|
+
)["data"]
|
|
271
|
+
|
|
272
|
+
def get_execution(
|
|
273
|
+
self,
|
|
274
|
+
workflow_id: str,
|
|
275
|
+
execution_id: str,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""Fetch one execution.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
PegasusApiError: On 404 (workflow not visible or execution not
|
|
281
|
+
part of this workflow).
|
|
282
|
+
"""
|
|
283
|
+
return self._get_json(
|
|
284
|
+
f"/api/v1/workflows/{workflow_id}/executions/{execution_id}",
|
|
285
|
+
)["data"]
|
|
286
|
+
|
|
287
|
+
def fork_workflow(self, workflow_id: str) -> dict[str, Any]:
|
|
288
|
+
"""Fork a GLOBAL platform-library workflow into the caller's tenant.
|
|
289
|
+
|
|
290
|
+
Copies the source workflow's artifact and manifest into a new
|
|
291
|
+
TENANT-visibility row owned by the caller — the one-click replacement
|
|
292
|
+
for the download-and-reupload workaround.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
workflow_id: The GLOBAL source workflow's id.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The created ``WorkflowResponse`` object.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
PegasusApiError: On 404 (source not found or not GLOBAL),
|
|
302
|
+
409 (a workflow with the same name@version already exists),
|
|
303
|
+
or any other non-2xx.
|
|
304
|
+
"""
|
|
305
|
+
with self._client() as client:
|
|
306
|
+
response = client.post(f"/api/v1/workflows/{workflow_id}/fork")
|
|
307
|
+
_raise_for_status(response)
|
|
308
|
+
return response.json()["data"]
|
|
309
|
+
|
|
310
|
+
def list_workflows(self) -> list[dict[str, Any]]:
|
|
311
|
+
"""List every workflow visible to the caller's tenant (∪ GLOBAL)."""
|
|
312
|
+
return self._get_json("/api/v1/workflows")["data"]
|
|
313
|
+
|
|
314
|
+
def get_workflow(self, workflow_id: str) -> dict[str, Any]:
|
|
315
|
+
"""Fetch a single workflow by id."""
|
|
316
|
+
return self._get_json(f"/api/v1/workflows/{workflow_id}")["data"]
|
|
317
|
+
|
|
318
|
+
def get_download_url(self, workflow_id: str) -> dict[str, Any]:
|
|
319
|
+
"""Get a presigned GET URL for a workflow's source zip.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
``{downloadUrl, expiresInSeconds}``.
|
|
323
|
+
"""
|
|
324
|
+
return self._get_json(f"/api/v1/workflows/{workflow_id}/download-url")["data"]
|
|
325
|
+
|
|
326
|
+
def download_artifact(self, workflow_id: str) -> bytes:
|
|
327
|
+
"""Download a workflow's source zip bytes."""
|
|
328
|
+
download_url = self.get_download_url(workflow_id)["downloadUrl"]
|
|
329
|
+
with self._bare_client() as client:
|
|
330
|
+
response = client.get(download_url)
|
|
331
|
+
_raise_for_status(response)
|
|
332
|
+
return response.content
|
|
333
|
+
|
|
334
|
+
# -- domain read helpers (for use inside activities) -------------------
|
|
335
|
+
|
|
336
|
+
def list_customers(self, **params: Any) -> Any:
|
|
337
|
+
"""Read the customers list. For use inside workflow activities."""
|
|
338
|
+
return self._get_json("/api/v1/customers", **params)
|
|
339
|
+
|
|
340
|
+
def list_quotes(self, **params: Any) -> Any:
|
|
341
|
+
"""Read the quotes list. For use inside workflow activities."""
|
|
342
|
+
return self._get_json("/api/v1/quotes", **params)
|
|
343
|
+
|
|
344
|
+
def list_moves(self, **params: Any) -> Any:
|
|
345
|
+
"""Read the moves list. For use inside workflow activities."""
|
|
346
|
+
return self._get_json("/api/v1/moves", **params)
|
|
347
|
+
|
|
348
|
+
def list_inventory(self, **params: Any) -> Any:
|
|
349
|
+
"""Read inventory rooms/items. For use inside workflow activities."""
|
|
350
|
+
return self._get_json("/api/v1/inventory", **params)
|
|
351
|
+
|
|
352
|
+
def list_invoices(self, **params: Any) -> Any:
|
|
353
|
+
"""Read the invoices list. For use inside workflow activities."""
|
|
354
|
+
return self._get_json("/api/v1/invoices", **params)
|
|
355
|
+
|
|
356
|
+
def list_events(self, **params: Any) -> Any:
|
|
357
|
+
"""Read the events stream. For use inside workflow activities."""
|
|
358
|
+
return self._get_json("/api/v1/events", **params)
|
|
359
|
+
|
|
360
|
+
def emit_event(
|
|
361
|
+
self,
|
|
362
|
+
name: str,
|
|
363
|
+
payload: dict[str, Any] | None = None,
|
|
364
|
+
) -> dict[str, Any]:
|
|
365
|
+
"""Emit an instance of a tenant-defined custom event by name.
|
|
366
|
+
|
|
367
|
+
Fires a custom event into the tenant's workflow engine — the same
|
|
368
|
+
DomainEvent outbox the dispatcher drains — so any workflow whose EVENT
|
|
369
|
+
trigger subscribes to ``name`` will run. This is the workflow-to-workflow
|
|
370
|
+
chaining primitive: one workflow emits ``name`` and another, triggered on
|
|
371
|
+
it, picks up the work.
|
|
372
|
+
|
|
373
|
+
The event type must already be registered in the tenant's event-type
|
|
374
|
+
registry. If that type declares a payload JSON Schema, ``payload`` is
|
|
375
|
+
validated against it server-side and a mismatch raises ``PegasusApiError``
|
|
376
|
+
(400).
|
|
377
|
+
|
|
378
|
+
Requires the workflow's manifest to declare
|
|
379
|
+
``required_actions = ["EmitTenantEvent"]``.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
name: The registered ``TenantEventType`` name (e.g. ``"lead.qualified"``).
|
|
383
|
+
payload: Arbitrary JSON payload. Defaults to ``{}``.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
``{emitted: True, eventType: name, occurredAt: <ISO-8601>}``.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
PegasusApiError: On 400 (payload fails the type's schema),
|
|
390
|
+
404 (event type not found or disabled), or any other non-2xx.
|
|
391
|
+
"""
|
|
392
|
+
with self._client() as client:
|
|
393
|
+
response = client.post(
|
|
394
|
+
f"/api/v1/event-types/{name}/emit",
|
|
395
|
+
json={"payload": payload or {}},
|
|
396
|
+
)
|
|
397
|
+
_raise_for_status(response)
|
|
398
|
+
return response.json()["data"]
|
|
399
|
+
|
|
400
|
+
# -- integration-validator config (publish / pull / versions / rollback) --
|
|
401
|
+
#
|
|
402
|
+
# The DB-backed authoring surface for an integration's declarative mapping +
|
|
403
|
+
# rules (apps/api/src/handlers/integration-validation/config.ts). A candidate
|
|
404
|
+
# supplies only the editable surface — ``mapping`` + ``rules`` — plus a golden
|
|
405
|
+
# ``corpus`` the server gates it against. Visibility is derived server-side
|
|
406
|
+
# from the caller's tenant (GLOBAL for the platform tenant, TENANT otherwise);
|
|
407
|
+
# the token must carry the ``PublishIntegrationConfig`` action to mutate.
|
|
408
|
+
|
|
409
|
+
def validate_integration_config(
|
|
410
|
+
self,
|
|
411
|
+
integration_id: str,
|
|
412
|
+
*,
|
|
413
|
+
mapping: Any,
|
|
414
|
+
rules: Any,
|
|
415
|
+
corpus: Any,
|
|
416
|
+
) -> dict[str, Any]:
|
|
417
|
+
"""Dry-run the publish gate for a candidate config. No write.
|
|
418
|
+
|
|
419
|
+
Runs the deterministic gate (mapping/rule static checks + golden-corpus
|
|
420
|
+
round-trip) server-side and returns the full report. Not flag-gated — a
|
|
421
|
+
usable pre-check anywhere. Inspect ``report["ok"]``.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
integration_id: The integration to gate against (e.g. ``"weichert"``).
|
|
425
|
+
mapping: The mapping document (editable surface).
|
|
426
|
+
rules: The rule set (editable surface).
|
|
427
|
+
corpus: The golden corpus — a list of ``GateCorpusCase`` objects.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
The ``GateReport``: ``{ok, problems, corpus: {total, passed, failures}}``.
|
|
431
|
+
|
|
432
|
+
Raises:
|
|
433
|
+
PegasusApiError: On 404 (unknown integration) or any other non-2xx.
|
|
434
|
+
"""
|
|
435
|
+
with self._client() as client:
|
|
436
|
+
response = client.post(
|
|
437
|
+
f"/api/v1/integrations/{integration_id}/config/validate",
|
|
438
|
+
json={"mapping": mapping, "rules": rules, "corpus": corpus},
|
|
439
|
+
)
|
|
440
|
+
_raise_for_status(response)
|
|
441
|
+
return response.json()["data"]
|
|
442
|
+
|
|
443
|
+
def publish_integration_config(
|
|
444
|
+
self,
|
|
445
|
+
integration_id: str,
|
|
446
|
+
*,
|
|
447
|
+
mapping: Any,
|
|
448
|
+
rules: Any,
|
|
449
|
+
corpus: Any,
|
|
450
|
+
) -> dict[str, Any]:
|
|
451
|
+
"""Gate then publish a config, creating a new version.
|
|
452
|
+
|
|
453
|
+
The server re-runs the gate and writes nothing if it fails (returns 422
|
|
454
|
+
``GATE_FAILED`` with the report). On success the live registry overlay is
|
|
455
|
+
refreshed so the new config serves immediately. Flag-gated behind the
|
|
456
|
+
server's ``INTEGRATION_CONFIG_PUBLISH_ENABLED`` switch.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
integration_id: The integration to publish.
|
|
460
|
+
mapping: The mapping document.
|
|
461
|
+
rules: The rule set.
|
|
462
|
+
corpus: The golden corpus the gate runs against.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
The created config row: ``{id, integrationId, version, visibility,
|
|
466
|
+
status, mapping, rules, corpus, publishedBy, createdAt}``.
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
PegasusApiError: On 403 (feature disabled), 404, 422 (gate failed —
|
|
470
|
+
the ``report`` is in the error body), or any other non-2xx.
|
|
471
|
+
"""
|
|
472
|
+
with self._client() as client:
|
|
473
|
+
response = client.post(
|
|
474
|
+
f"/api/v1/integrations/{integration_id}/config",
|
|
475
|
+
json={"mapping": mapping, "rules": rules, "corpus": corpus},
|
|
476
|
+
)
|
|
477
|
+
_raise_for_status(response)
|
|
478
|
+
return response.json()["data"]
|
|
479
|
+
|
|
480
|
+
def get_integration_config(self, integration_id: str) -> dict[str, Any]:
|
|
481
|
+
"""Fetch the active config for the caller's scope (TENANT ∪ GLOBAL).
|
|
482
|
+
|
|
483
|
+
The full projection — including the editable surface (mapping/rules/corpus)
|
|
484
|
+
— so a pulled config can be edited and republished (round-trip).
|
|
485
|
+
|
|
486
|
+
Raises:
|
|
487
|
+
PegasusApiError: On 404 (no published config for this scope).
|
|
488
|
+
"""
|
|
489
|
+
return self._get_json(f"/api/v1/integrations/{integration_id}/config")["data"]
|
|
490
|
+
|
|
491
|
+
def list_integration_config_versions(self, integration_id: str) -> list[dict[str, Any]]:
|
|
492
|
+
"""List the config version history for the caller's scope, newest first.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
A list of compact summaries (no mapping/rules/corpus blobs):
|
|
496
|
+
``{id, integrationId, version, visibility, status, publishedBy, createdAt}``.
|
|
497
|
+
"""
|
|
498
|
+
return self._get_json(
|
|
499
|
+
f"/api/v1/integrations/{integration_id}/config/versions"
|
|
500
|
+
)["data"]
|
|
501
|
+
|
|
502
|
+
def rollback_integration_config(
|
|
503
|
+
self,
|
|
504
|
+
integration_id: str,
|
|
505
|
+
version: int,
|
|
506
|
+
) -> dict[str, Any]:
|
|
507
|
+
"""Re-publish a prior version as a new version.
|
|
508
|
+
|
|
509
|
+
The server re-runs the gate against the rolled-back config: one that
|
|
510
|
+
passed when first published may no longer pass if the canonical contract
|
|
511
|
+
has since changed in code, in which case it returns 422. Flag-gated.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
integration_id: The integration to roll back.
|
|
515
|
+
version: The existing version number to re-publish.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
The newly-created config row (a fresh version number).
|
|
519
|
+
|
|
520
|
+
Raises:
|
|
521
|
+
PegasusApiError: On 403 (feature disabled), 404 (version not found),
|
|
522
|
+
422 (gate failed), or any other non-2xx.
|
|
523
|
+
"""
|
|
524
|
+
with self._client() as client:
|
|
525
|
+
response = client.post(
|
|
526
|
+
f"/api/v1/integrations/{integration_id}/config/rollback/{version}",
|
|
527
|
+
)
|
|
528
|
+
_raise_for_status(response)
|
|
529
|
+
return response.json()["data"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The ``pegasus-workflows`` command-line interface.
|
|
2
|
+
|
|
3
|
+
A Typer application wiring together the workflow developer flow:
|
|
4
|
+
|
|
5
|
+
* ``init`` — scaffold a new workflow project.
|
|
6
|
+
* ``package`` — zip each declared workflow into ``dist/``.
|
|
7
|
+
* ``push`` — package, then upload + finalize against the Pegasus API.
|
|
8
|
+
* ``run`` — trigger a server-side execution of a curated workflow.
|
|
9
|
+
* ``test`` — start local Temporal and run a workflow with stubbed inputs.
|
|
10
|
+
* ``integration-config`` — author the integration-validator config (mapping +
|
|
11
|
+
rules) for an integration (publish / pull / versions / rollback).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from .init import init_command
|
|
19
|
+
from .integration_config import integration_config_app
|
|
20
|
+
from .package import package_command
|
|
21
|
+
from .push import push_command
|
|
22
|
+
from .run import run_command
|
|
23
|
+
from .test import test_command
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="pegasus-workflows",
|
|
27
|
+
help="Author, package, and publish Pegasus workflows.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app.command("init")(init_command)
|
|
33
|
+
app.command("package")(package_command)
|
|
34
|
+
app.command("push")(push_command)
|
|
35
|
+
app.command("run")(run_command)
|
|
36
|
+
app.command("test")(test_command)
|
|
37
|
+
app.add_typer(integration_config_app)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
"""Console-script entry point (see ``[project.scripts]``)."""
|
|
42
|
+
app()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["app", "main"]
|