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.
@@ -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
@@ -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"]