@uipath/maestro-builder-sdk 6.4.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/cli/commands/registry.js +68 -12
  2. package/dist/registry/archive/entry-policy.d.ts +36 -0
  3. package/dist/registry/archive/entry-policy.js +60 -0
  4. package/dist/registry/archive/extract.d.ts +31 -0
  5. package/dist/registry/archive/extract.js +92 -0
  6. package/dist/registry/archive/zip-reader.d.ts +38 -0
  7. package/dist/registry/archive/zip-reader.js +256 -0
  8. package/dist/registry/codecs/pyjson.d.ts +62 -0
  9. package/dist/registry/codecs/pyjson.js +208 -0
  10. package/dist/registry/compat/compare.d.ts +18 -0
  11. package/dist/registry/compat/compare.js +32 -0
  12. package/dist/registry/compat/paths.d.ts +4 -0
  13. package/dist/registry/compat/paths.js +17 -0
  14. package/dist/registry/compat/pystr.d.ts +50 -0
  15. package/dist/registry/compat/pystr.js +221 -0
  16. package/dist/registry/concurrency.d.ts +10 -0
  17. package/dist/registry/concurrency.js +34 -0
  18. package/dist/registry/curated-overlay.d.ts +36 -4
  19. package/dist/registry/curated-overlay.js +87 -14
  20. package/dist/registry/errors.d.ts +26 -0
  21. package/dist/registry/errors.js +38 -0
  22. package/dist/registry/extractor.d.ts +9 -10
  23. package/dist/registry/extractor.js +5 -15
  24. package/dist/registry/files.d.ts +26 -0
  25. package/dist/registry/files.js +160 -0
  26. package/dist/registry/generators/descriptors.d.ts +81 -0
  27. package/dist/registry/generators/descriptors.js +405 -0
  28. package/dist/registry/generators/fields.d.ts +46 -0
  29. package/dist/registry/generators/fields.js +111 -0
  30. package/dist/registry/generators/markdown/common.d.ts +21 -0
  31. package/dist/registry/generators/markdown/common.js +55 -0
  32. package/dist/registry/generators/markdown/index.d.ts +33 -0
  33. package/dist/registry/generators/markdown/index.js +37 -0
  34. package/dist/registry/generators/markdown/page.d.ts +8 -0
  35. package/dist/registry/generators/markdown/page.js +52 -0
  36. package/dist/registry/generators/markdown/pyvalue.d.ts +34 -0
  37. package/dist/registry/generators/markdown/pyvalue.js +106 -0
  38. package/dist/registry/generators/markdown/sections/choosing-object.d.ts +2 -0
  39. package/dist/registry/generators/markdown/sections/choosing-object.js +52 -0
  40. package/dist/registry/generators/markdown/sections/example.d.ts +22 -0
  41. package/dist/registry/generators/markdown/sections/example.js +295 -0
  42. package/dist/registry/generators/markdown/sections/filtering.d.ts +2 -0
  43. package/dist/registry/generators/markdown/sections/filtering.js +76 -0
  44. package/dist/registry/generators/markdown/sections/header.d.ts +2 -0
  45. package/dist/registry/generators/markdown/sections/header.js +37 -0
  46. package/dist/registry/generators/markdown/sections/inputs.d.ts +30 -0
  47. package/dist/registry/generators/markdown/sections/inputs.js +151 -0
  48. package/dist/registry/generators/markdown/sections/lookups.d.ts +12 -0
  49. package/dist/registry/generators/markdown/sections/lookups.js +150 -0
  50. package/dist/registry/generators/markdown/sections/outputs.d.ts +3 -0
  51. package/dist/registry/generators/markdown/sections/outputs.js +9 -0
  52. package/dist/registry/generators/markdown/sections/resolving-fields.d.ts +2 -0
  53. package/dist/registry/generators/markdown/sections/resolving-fields.js +139 -0
  54. package/dist/registry/generators/markdown/sections/schema-dynamic.d.ts +2 -0
  55. package/dist/registry/generators/markdown/sections/schema-dynamic.js +86 -0
  56. package/dist/registry/generators/materialize.d.ts +30 -0
  57. package/dist/registry/generators/materialize.js +132 -0
  58. package/dist/registry/generators/md-symbols.d.ts +22 -0
  59. package/dist/registry/generators/md-symbols.js +58 -0
  60. package/dist/registry/generators/triggers.d.ts +15 -0
  61. package/dist/registry/generators/triggers.js +85 -0
  62. package/dist/registry/generators/type-registry.d.ts +50 -0
  63. package/dist/registry/generators/type-registry.js +154 -0
  64. package/dist/registry/generators/type-tree.d.ts +65 -0
  65. package/dist/registry/generators/type-tree.js +179 -0
  66. package/dist/registry/library-build/build.d.ts +57 -0
  67. package/dist/registry/library-build/build.js +286 -0
  68. package/dist/registry/library-build/canonical.d.ts +119 -0
  69. package/dist/registry/library-build/canonical.js +512 -0
  70. package/dist/registry/library-build/cli.d.ts +45 -0
  71. package/dist/registry/library-build/cli.js +182 -0
  72. package/dist/registry/library-build/context.d.ts +107 -0
  73. package/dist/registry/library-build/context.js +267 -0
  74. package/dist/registry/library-build/enrich.d.ts +36 -0
  75. package/dist/registry/library-build/enrich.js +158 -0
  76. package/dist/registry/library-build/events.d.ts +108 -0
  77. package/dist/registry/library-build/events.js +476 -0
  78. package/dist/registry/library-build/generic-expand.d.ts +77 -0
  79. package/dist/registry/library-build/generic-expand.js +363 -0
  80. package/dist/registry/library-build/plan.d.ts +71 -0
  81. package/dist/registry/library-build/plan.js +270 -0
  82. package/dist/registry/library-build/py.d.ts +45 -0
  83. package/dist/registry/library-build/py.js +167 -0
  84. package/dist/registry/library-build/schema-action.d.ts +38 -0
  85. package/dist/registry/library-build/schema-action.js +107 -0
  86. package/dist/registry/library-build/write.d.ts +6 -0
  87. package/dist/registry/library-build/write.js +26 -0
  88. package/dist/registry/library-reader.d.ts +20 -0
  89. package/dist/registry/library-reader.js +50 -0
  90. package/dist/registry/lookup-specs.d.ts +55 -0
  91. package/dist/registry/lookup-specs.js +140 -0
  92. package/dist/registry/materialize-cli.js +28 -14
  93. package/dist/registry/materializer.d.ts +25 -3
  94. package/dist/registry/materializer.js +45 -29
  95. package/dist/registry/model/library.d.ts +254 -0
  96. package/dist/registry/model/library.js +23 -0
  97. package/dist/registry/naming.d.ts +54 -0
  98. package/dist/registry/naming.js +142 -0
  99. package/dist/registry/orphans.d.ts +4 -0
  100. package/dist/registry/orphans.js +86 -0
  101. package/dist/registry/prepare/actions.d.ts +26 -0
  102. package/dist/registry/prepare/actions.js +94 -0
  103. package/dist/registry/prepare/connections.d.ts +181 -0
  104. package/dist/registry/prepare/connections.js +399 -0
  105. package/dist/registry/prepare/event.d.ts +96 -0
  106. package/dist/registry/prepare/event.js +268 -0
  107. package/dist/registry/prepare/handoff.d.ts +21 -0
  108. package/dist/registry/prepare/handoff.js +91 -0
  109. package/dist/registry/prepare/ledger.d.ts +104 -0
  110. package/dist/registry/prepare/ledger.js +216 -0
  111. package/dist/registry/prepare/messages.d.ts +190 -0
  112. package/dist/registry/prepare/messages.js +472 -0
  113. package/dist/registry/prepare/objects.d.ts +130 -0
  114. package/dist/registry/prepare/objects.js +318 -0
  115. package/dist/registry/prepare/overlay.d.ts +149 -0
  116. package/dist/registry/prepare/overlay.js +487 -0
  117. package/dist/registry/prepare/parents.d.ts +41 -0
  118. package/dist/registry/prepare/parents.js +122 -0
  119. package/dist/registry/prepare/pipeline.d.ts +91 -0
  120. package/dist/registry/prepare/pipeline.js +376 -0
  121. package/dist/registry/prepare/regenerate.d.ts +9 -0
  122. package/dist/registry/prepare/regenerate.js +50 -0
  123. package/dist/registry/prepare/resolve.d.ts +199 -0
  124. package/dist/registry/prepare/resolve.js +470 -0
  125. package/dist/registry/prepare/retry.d.ts +83 -0
  126. package/dist/registry/prepare/retry.js +109 -0
  127. package/dist/registry/prepare/source-import.d.ts +54 -0
  128. package/dist/registry/prepare/source-import.js +192 -0
  129. package/dist/registry/prepare/support.d.ts +41 -0
  130. package/dist/registry/prepare/support.js +178 -0
  131. package/dist/registry/prepare-cli.d.ts +16 -5
  132. package/dist/registry/prepare-cli.js +60 -53
  133. package/dist/registry/pull-cli.js +8 -7
  134. package/dist/registry/reporter.d.ts +26 -0
  135. package/dist/registry/reporter.js +41 -0
  136. package/dist/registry/uip/client.d.ts +99 -0
  137. package/dist/registry/uip/client.js +226 -0
  138. package/dist/registry/uip/commands.d.ts +40 -0
  139. package/dist/registry/uip/commands.js +80 -0
  140. package/dist/registry/uip/envelope.d.ts +37 -0
  141. package/dist/registry/uip/envelope.js +84 -0
  142. package/dist/registry/uip/response-cache.d.ts +28 -0
  143. package/dist/registry/uip/response-cache.js +57 -0
  144. package/lib/README.md +10 -8
  145. package/package.json +3 -3
  146. package/dist/generators/_connections.py +0 -331
  147. package/dist/generators/_events.py +0 -355
  148. package/dist/generators/_lookups.py +0 -114
  149. package/dist/generators/_naming.py +0 -185
  150. package/dist/generators/_objects.py +0 -299
  151. package/dist/generators/_resolve.py +0 -319
  152. package/dist/generators/convert_library_to_md.py +0 -1441
  153. package/dist/generators/extract_library.py +0 -125
  154. package/dist/generators/generate_connectors_ts.py +0 -936
  155. package/dist/generators/generate_library_json.py +0 -2527
  156. package/dist/generators/generate_triggers_ts.py +0 -110
  157. package/dist/generators/materialize_registry.py +0 -155
  158. package/dist/generators/prepare_connector.py +0 -1860
  159. package/dist/registry/python.d.ts +0 -26
  160. package/dist/registry/python.js +0 -46
@@ -1,331 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Find the connection for a connector, and write its bindings.
3
-
4
- `prepare` already knows which connector it is preparing. Making the author go
5
- and find that connector's connection id by hand — and then its folder key, and
6
- then hand-write `bindings.json` around both — is work the command can do itself
7
- from information it already has.
8
-
9
- Measured on the eval corpus, that hand-work was 4 to 7 tool calls per task:
10
- listing connections, filtering them, then (because nothing said the folder key
11
- was on the connection record) three or four more spent searching Orchestrator
12
- folders for one matching the symbolic name in the prompt.
13
-
14
- THE ONE THING THIS MUST NOT DO IS GUESS. A tenant can carry several connections
15
- for the same connector — this repo's test tenant has five for Slack — and they
16
- are not interchangeable: a user who exists in one workspace does not exist in
17
- another, so picking the wrong one makes a lookup unresolvable for a reason that
18
- points nowhere near the actual mistake. That failure was observed directly
19
- (`skill-flow-slack-dm-lookup` named the wrong workspace and burned its whole
20
- turn budget), which is why ambiguity here is an error listing the candidates and
21
- never a silent choice.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import json
27
- import subprocess
28
-
29
- __all__ = [
30
- "ConnectionError_",
31
- "byoa_connections",
32
- "byoa_listing",
33
- "candidate_connections",
34
- "candidate_listing",
35
- "discover_connection",
36
- "lookup_connection",
37
- "merge_bindings",
38
- ]
39
-
40
-
41
- class ConnectionError_(Exception):
42
- """No usable connection, with the reason and the way forward."""
43
-
44
-
45
- def _list(all_folders: bool, byoa: bool = False) -> list[dict]:
46
- command = ["uip", "is", "connections", "list", "--output", "json"]
47
- if all_folders:
48
- command.insert(4, "--all-folders")
49
- if byoa:
50
- command.insert(4, "--byoa")
51
- result = subprocess.run(command, capture_output=True, text=True)
52
- if result.returncode != 0:
53
- raise ConnectionError_(
54
- "listing connections failed: "
55
- f"{result.stderr.strip() or 'no output'}"
56
- )
57
- try:
58
- payload = json.loads(result.stdout)
59
- except ValueError as error:
60
- raise ConnectionError_(f"connection listing was not JSON: {error}") from error
61
- data = payload.get("Data")
62
- if isinstance(data, dict):
63
- data = data.get("items")
64
- return data if isinstance(data, list) else []
65
-
66
-
67
- def _matching(rows: list[dict], connector_key: str, name: str | None) -> list[dict]:
68
- out = [
69
- row for row in rows
70
- if row.get("ConnectorKey") == connector_key
71
- # A Failed connection is listed like any other and cannot serve a
72
- # request; selecting one produces an error about the operation rather
73
- # than about the connection, which sends the reader the wrong way.
74
- and str(row.get("State", "")).lower() == "enabled"
75
- ]
76
- if name:
77
- out = [row for row in out if row.get("Name") == name]
78
- return out
79
-
80
-
81
- def discover_connection(connector_key: str, name: str | None = None) -> dict:
82
- """The one enabled connection for `connector_key`, or raise.
83
-
84
- Tries the DEFAULT listing first and only widens to `--all-folders` if that
85
- finds nothing. That order matters and is not just politeness to the API: the
86
- default listing is the caller's own folder, which on a real tenant is one
87
- connection where the tenant-wide view is many. Widening first would turn an
88
- unambiguous case into an ambiguous one.
89
- """
90
- rows = _matching(_list(all_folders=False), connector_key, name)
91
- scope = "your folder"
92
- if not rows:
93
- rows = _matching(_list(all_folders=True), connector_key, name)
94
- scope = "the tenant"
95
-
96
- if not rows:
97
- qualifier = f" named {name!r}" if name else ""
98
- raise ConnectionError_(
99
- f"no enabled {connector_key} connection{qualifier} found in {scope}. "
100
- f"List what exists with:\n"
101
- f" uip is connections list --all-folders"
102
- )
103
- if len(rows) > 1:
104
- # Every candidate line carries its id: a NAME is not a key on a tenant.
105
- # The eval tenant holds three Slack connections all named
106
- # `is-sandboxes` (two Enabled, in different folders), so the old remedy —
107
- # "name the one you want with --connection <name>" — handed back the very
108
- # flag the caller had just used, and the doc-following author dead-ended
109
- # (skill-flow-e2e-escalation-orchestrator-paths, 2026-09-01: placeholder
110
- # GUIDs hand-written into bindings.json, 401 at the Slack node).
111
- listed = "\n".join(
112
- f" {row.get('Name')} --connection-id {row.get('Id')}"
113
- f" (folder: {row.get('Folder')})"
114
- for row in rows
115
- )
116
- names = {str(row.get("Name")) for row in rows}
117
- if name is not None or len(names) < len(rows):
118
- remedy = (
119
- "They share a name, so --connection <name> cannot pick one; pass "
120
- "the id of the one in the folder you mean with --connection-id:"
121
- )
122
- else:
123
- remedy = (
124
- "Name the one you want with --connection <name>, or pass its id "
125
- "with --connection-id:"
126
- )
127
- raise ConnectionError_(
128
- f"{len(rows)} enabled {connector_key} connections in {scope}, and they "
129
- f"are not interchangeable — a record that exists in one workspace need "
130
- f"not exist in another. {remedy}\n{listed}"
131
- )
132
- return rows[0]
133
-
134
-
135
- def candidate_connections(connector_key: str, name: str | None = None) -> list[dict]:
136
- """Every enabled connection for `connector_key`, own folder FIRST.
137
-
138
- `discover_connection` answers "which one" and stops at the first non-empty
139
- scope, which is right for picking a connection and wrong for retrying one:
140
- when the own-folder listing holds exactly one, the tenant-wide connections
141
- are never even looked at. So a lookup that exhausts the personal-workspace
142
- connection reports a bad VALUE while the record sits in another folder's
143
- connection (flow-builder-sdk#744, measured on two Jira connections where
144
- only the second carried project `TS`).
145
-
146
- Ordering is the same preference `discover_connection` encodes — the caller's
147
- own folder is the likeliest and is tried first — but nothing is dropped.
148
- Deduplicated by `Id`, because the tenant-wide listing repeats the own-folder
149
- rows.
150
-
151
- This does NOT decide anything, and in particular does not raise on several
152
- matches: choosing among them is the caller's job, and the only honest way to
153
- choose is evidence (a lookup that resolves on exactly one). That keeps this
154
- module's rule intact — ambiguity is never resolved by guessing.
155
- """
156
- ordered: list[dict] = []
157
- seen: set[str] = set()
158
- for all_folders in (False, True):
159
- for row in _matching(_list(all_folders=all_folders), connector_key, name):
160
- identifier = str(row.get("Id", ""))
161
- if identifier and identifier in seen:
162
- continue
163
- seen.add(identifier)
164
- ordered.append(row)
165
- return ordered
166
-
167
-
168
- def candidate_listing(rows: list[dict], indent: str = " ") -> str:
169
- """The candidates, one pasteable `--connection-id` line each."""
170
- return "\n".join(
171
- f"{indent}{row.get('Name')} --connection-id {row.get('Id')}"
172
- f" (folder: {row.get('Folder')})"
173
- for row in rows
174
- )
175
-
176
-
177
- def _enabled_listing(rows: list[dict], connector_key: str) -> str:
178
- """The enabled connections for `connector_key`, one per line, pasteable."""
179
- enabled = _matching(rows, connector_key, None)
180
- if not enabled:
181
- return (
182
- f" (none — this tenant has no Enabled {connector_key} connection; "
183
- "repair or create one in Integration Service first)"
184
- )
185
- return "\n".join(
186
- f" {row.get('Name')} --connection-id {row.get('Id')}"
187
- f" (folder: {row.get('Folder')})"
188
- for row in enabled
189
- )
190
-
191
-
192
- def lookup_connection(connector_key: str, connection_id: str) -> dict:
193
- """The record for an explicitly named connection, or raise unless it can serve.
194
-
195
- An explicit `--connection-id` used to skip every check discovery applies, and
196
- that skip is where a whole class of silent failures came from. A connection
197
- in `State: Failed` is listed like any other, and `uip is resources describe`
198
- against it answers `Result: Success` with NO request fields — the tenant has
199
- no schema to give for a connection it cannot reach. `prepare` then wrote an
200
- overlay with zero body inputs, printed "0 input field(s)" as a success, and
201
- the next `compile` rejected every real field as unknown, sending the author
202
- hunting through the SDK for a flag that does not exist.
203
-
204
- Measured on the eval tenant (Salesforce `Opportunity` / `Update`): the Failed
205
- connection returned 0 request fields, the Enabled one 13 (`StageName` among
206
- them). The state was visible in the same `connections list` the author had
207
- already run; a projection that dropped the `State` column hid it.
208
-
209
- So: resolve the id against the tenant-wide listing, and refuse — naming the
210
- state found and the enabled alternatives — rather than describe through it.
211
- """
212
- rows = _list(all_folders=True)
213
- match = next(
214
- (row for row in rows if str(row.get("Id", "")) == str(connection_id)), None
215
- )
216
- alternatives = _enabled_listing(rows, connector_key)
217
- if match is None:
218
- raise ConnectionError_(
219
- f"connection {connection_id} is not in this tenant's listing "
220
- f"(`uip is connections list --all-folders`). Enabled {connector_key} "
221
- f"connections:\n{alternatives}\n"
222
- "Or omit --connection-id and prepare selects the connection itself."
223
- )
224
- actual_key = match.get("ConnectorKey")
225
- if actual_key and actual_key != connector_key:
226
- raise ConnectionError_(
227
- f"connection {match.get('Name')} ({connection_id}) belongs to connector "
228
- f"{actual_key}, not {connector_key}. Enabled {connector_key} "
229
- f"connections:\n{alternatives}\n"
230
- "Or omit --connection-id and prepare selects the connection itself."
231
- )
232
- state = str(match.get("State", "") or "")
233
- if state.lower() != "enabled":
234
- shown = state or "in an unknown state"
235
- raise ConnectionError_(
236
- f"connection {match.get('Name')} ({connection_id}) is {shown}, not "
237
- "Enabled. A connection that is not Enabled answers field discovery "
238
- "with an EMPTY schema, so the overlay would carry no fields and the "
239
- "next compile would still reject every input as unknown. Enabled "
240
- f"{connector_key} connections:\n{alternatives}\n"
241
- "Or omit --connection-id and prepare selects the connection itself."
242
- )
243
- return match
244
-
245
-
246
- def byoa_connections(connector_key: str) -> list[dict]:
247
- """The enabled BYOA connections for `connector_key`.
248
-
249
- RAISES on a failed listing rather than answering an empty list. The
250
- difference is the whole point: "you have no BYOA connection" tells the
251
- caller to go create one, and a listing that failed says nothing of the
252
- kind. Answering [] for both turns a broken call into confident, wrong
253
- advice — the failure-is-not-emptiness rule this module already applies to
254
- ordinary discovery.
255
- """
256
- return _matching(_list(all_folders=True, byoa=True), connector_key, None)
257
-
258
-
259
- def byoa_listing(rows: list[dict], connector_key: str) -> str:
260
- """The BYOA candidates, one pasteable line each, or why there are none."""
261
- if not rows:
262
- return (
263
- f" (none — this tenant has no enabled BYOA {connector_key} "
264
- "connection; create one in Integration Service with your own "
265
- "OAuth app, then re-run)"
266
- )
267
- return "\n".join(
268
- f" {row.get('Name')} --connection-id {row.get('Id')}"
269
- f" (folder: {row.get('Folder')})"
270
- for row in rows
271
- )
272
-
273
-
274
- def merge_bindings(
275
- path: str,
276
- connection: dict,
277
- connection_name: str,
278
- folder_name: str,
279
- ) -> tuple[bool, bool]:
280
- """Add connection + folder entries to `bindings.json`, in place.
281
-
282
- ADDITIVE AND IDEMPOTENT. An existing entry under the same symbolic name is
283
- updated, never duplicated, and every other entry is left exactly as it was —
284
- `bindings.json` is an authored file that may already carry bindings for
285
- connectors this run knows nothing about.
286
-
287
- Returns `(wrote_connection, wrote_folder)` describing what changed, so the
288
- caller can report the specific thing rather than "updated bindings".
289
- """
290
- import os
291
-
292
- document: dict = {"schemaVersion": "1", "bindings": []}
293
- if os.path.exists(path):
294
- try:
295
- with open(path, encoding="utf-8") as handle:
296
- loaded = json.load(handle)
297
- if isinstance(loaded, dict):
298
- document = loaded
299
- document.setdefault("schemaVersion", "1")
300
- document.setdefault("bindings", [])
301
- except (OSError, ValueError):
302
- # A malformed bindings.json is the author's file and not ours to
303
- # discard: refuse rather than overwrite work we cannot read.
304
- raise ConnectionError_(
305
- f"{path} exists but is not readable JSON. Fix or remove it; "
306
- "this command will not overwrite a file it cannot parse."
307
- )
308
-
309
- def upsert(symbolic: str, key: str, attribute: str) -> bool:
310
- for entry in document["bindings"]:
311
- if entry.get("name") == symbolic or entry.get("id") == symbolic:
312
- changed = entry.get("resourceKey") != key
313
- entry.update({
314
- "id": entry.get("id", symbolic), "name": symbolic,
315
- "resource": "Connection", "resourceKey": key,
316
- "default": key, "propertyAttribute": attribute,
317
- })
318
- return changed
319
- document["bindings"].append({
320
- "id": symbolic, "name": symbolic, "resource": "Connection",
321
- "resourceKey": key, "default": key, "propertyAttribute": attribute,
322
- })
323
- return True
324
-
325
- wrote_connection = upsert(connection_name, str(connection["Id"]), "ConnectionId")
326
- wrote_folder = upsert(folder_name, str(connection["FolderKey"]), "FolderKey")
327
-
328
- with open(path, "w", encoding="utf-8") as handle:
329
- json.dump(document, handle, indent=2)
330
- handle.write("\n")
331
- return wrote_connection, wrote_folder
@@ -1,355 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Deterministic discovery for connector EVENTS — triggers and mid-flow waits.
3
-
4
- The activities pipeline resolves a connector operation's schema through
5
- `uip is resources list`/`describe`. Events have their own twin pair —
6
- `uip is triggers objects` / `uip is triggers describe` — plus the product
7
- registry's node manifest (`uip maestro flow registry get`, which for trigger
8
- node types REFUSES to answer without a live `--connection-id`, and which
9
- mirrors the describe sections when it does).
10
-
11
- Two shapes matter here and they are deliberately kept apart:
12
-
13
- - `triggers objects` answers PER EVENT OBJECT: the object catalog a generic
14
- event ranges over, and per object the facts that change how a subscription
15
- must be prepared (`ByoaConnection`, `IsWebhookUrlVisible`) and the
16
- `Parameters` the object contributes to the input set.
17
- - `triggers describe` answers PER (event, object): `EventParameters`,
18
- `FilterFields`, `OutputFields`.
19
-
20
- The V1 skill's source-of-truth contract is preserved verbatim: the complete
21
- event-input set is the UNION of `objects → Parameters` and
22
- `describe → EventParameters`, required if EITHER side says required. A field
23
- can appear in one and not the other (measured in the V1 corpus: required
24
- fields present only in describe), so neither call alone is trusted.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import json
30
- import subprocess
31
-
32
- __all__ = [
33
- "EventDiscoveryError",
34
- "triggers_objects",
35
- "triggers_describe",
36
- "camel_fields",
37
- "normalize_bucket_fields",
38
- "union_event_parameters",
39
- "merge_describe_into_node",
40
- "event_facts",
41
- ]
42
-
43
- # `Parameters[].type` on a `triggers objects` row routes the value into one of
44
- # three `--detail` buckets at emit time. Everything unrecognized is an event
45
- # parameter — that is the designer's own fallback, and inventing a fourth
46
- # bucket here would emit a detail key the validator refuses.
47
- _BUCKETS = {"query": "query", "path": "path"}
48
-
49
-
50
- class EventDiscoveryError(Exception):
51
- """A trigger metadata call failed, with the reason and the way forward."""
52
-
53
-
54
- def _run_json(command: list[str]) -> dict:
55
- result = subprocess.run(command, capture_output=True, text=True)
56
- try:
57
- payload = json.loads(result.stdout)
58
- except ValueError:
59
- raise EventDiscoveryError(
60
- f"`{' '.join(command[:5])} …` did not answer JSON: "
61
- f"{(result.stderr or result.stdout).strip()[:300] or 'no output'}"
62
- )
63
- if payload.get("Result") != "Success":
64
- raise EventDiscoveryError(
65
- f"`{' '.join(command[:5])} …` failed: "
66
- f"{payload.get('Message') or payload.get('Result')}"
67
- )
68
- return payload
69
-
70
-
71
- def triggers_objects(
72
- key: str, operation: str, connection_id: str | None = None,
73
- ) -> list[dict]:
74
- """The event objects for `(connector, operation)`, PascalCase rows.
75
-
76
- Rows carry `Name`/`DisplayName` (the same identity pair the activities
77
- object ladder matches on) and, where the connector declares them,
78
- `ByoaConnection`, `IsWebhookUrlVisible` and `Parameters`. Connectors that
79
- declare none simply omit the keys (measured: sfdc's 1,178 CREATED rows
80
- carry neither), so every reader treats them as optional.
81
-
82
- `connection_id` is OPTIONAL, and what omitting it means depends on the
83
- connector's object discovery. A connector whose objects do not come from
84
- the connection answers identically either way (outlook `CREATED`:
85
- Message + Calendar, with and without), which is what lets generation bake
86
- those objects with no credentials at all. One whose objects DO come from
87
- the connection answers with its declared set instead of the tenant's
88
- (sfdc `CREATED`: 4 offline, 1,178 against a connection) — so a caller that
89
- needs the tenant's objects must pass the id.
90
- """
91
- command = ["uip", "is", "triggers", "objects", key, operation]
92
- if connection_id:
93
- command += ["--connection-id", connection_id]
94
- payload = _run_json(command + ["--output", "json"])
95
- data = payload.get("Data")
96
- if isinstance(data, dict):
97
- data = data.get("items")
98
- return data if isinstance(data, list) else []
99
-
100
-
101
- def triggers_describe(
102
- key: str, operation: str, object_name: str,
103
- connection_id: str | None = None,
104
- ) -> dict:
105
- """`EventParameters` / `FilterFields` / `OutputFields` for one object.
106
-
107
- This call takes no node type — connector, operation and object only — so
108
- it answers for the EVENT rather than for either of the two node forms the
109
- platform ships it as. That is why both forms carry the same schema, and
110
- why the product registry's per-node copy is a mirror of this: a lossy one,
111
- measured on outlook `email-received` at 14 filter fields to this call's 15.
112
-
113
- `connection_id` is optional here too; see `triggers_objects` for what
114
- omitting it costs on a connector whose objects come from the connection.
115
- """
116
- command = ["uip", "is", "triggers", "describe", key, operation, object_name]
117
- if connection_id:
118
- command += ["--connection-id", connection_id]
119
- payload = _run_json(command + ["--output", "json"])
120
- return payload.get("Data") or {}
121
-
122
-
123
- def _camelize(value):
124
- """Lower-case the first letter of every key, recursively.
125
-
126
- `triggers describe` answers PascalCase all the way down, including the
127
- `Reference` block: `{ObjectName, LookupNames, LookupValue, Path}`. Every
128
- consumer of a reference — the lookup-spec generator, the `--resolve`
129
- executor, the markdown — reads the registry's camelCase spelling, so a
130
- verbatim copy is invisible to all of them: the field simply stops having
131
- a lookup. One shape leaves this module, and this is what makes it one.
132
- """
133
- if isinstance(value, dict):
134
- return {
135
- (k[0].lower() + k[1:] if k[:1].isupper() else k): _camelize(v)
136
- for k, v in value.items()
137
- }
138
- if isinstance(value, list):
139
- return [_camelize(v) for v in value]
140
- return value
141
-
142
-
143
- def _is_hidden(low: dict) -> bool:
144
- """True for a hidden parameter that is safe to DROP — hidden and optional.
145
-
146
- Outlook's `sharedMailboxAddress` is `Design.IsHidden: true` and optional:
147
- a transport detail of the subscription, not something an author fills in,
148
- so counting it would advertise a second field on an event whose only real
149
- parameter is the mail folder.
150
-
151
- A hidden parameter that is REQUIRED is a different animal and is kept
152
- (flagged `hidden`, see `_keep_hidden_marker`). Asana's `parent` and
153
- `projectId` are both — path-bucket, required, hidden — and dropping a
154
- value the subscription needs at dispatch is exactly the silent wrongness
155
- this design exists to remove. Keeping it flagged loses nothing: a surface
156
- that should not prompt for it can read the flag, while nothing can lose
157
- it by accident.
158
- """
159
- design = low.get("design") or {}
160
- if not isinstance(design, dict):
161
- return False
162
- # Only the TOP-level keys are lowered by the callers, so the nested block
163
- # still reads `{"IsHidden": true}`. Camelize it before asking.
164
- design = _camelize(design)
165
- hidden = bool(design.get("isHidden") or design.get("hidden"))
166
- return hidden and not low.get("required")
167
-
168
-
169
- def _keep_hidden_marker(low: dict, field: dict) -> dict:
170
- """Flag a kept parameter the designer hides (necessarily a required one)."""
171
- design = _camelize(low.get("design") or {})
172
- if isinstance(design, dict) and (design.get("isHidden") or design.get("hidden")):
173
- field["hidden"] = True
174
- return field
175
-
176
-
177
- def _with_cascade(fields: list[dict]) -> list[dict]:
178
- """Mark each parameter that another one has to resolve before it.
179
-
180
- Jira's `issuetype` reference is `/project/{project}/issuetypes` — the
181
- cascade is written into the path, as a placeholder naming the parameter it
182
- needs. That is the same encoding `_order_by_dependency` reads for an
183
- activity's parent fields, and `_resolve.order_resolutions` already honours
184
- `dependsOn`, so recording it here is what makes `prepare` resolve
185
- `project` first without being told to.
186
- """
187
- names = {f.get("name") for f in fields}
188
- for field in fields:
189
- reference = field.get("reference") or {}
190
- path = str(reference.get("path") or "")
191
- explicit = list(reference.get("dependsOn") or [])
192
- depends = sorted(
193
- other for other in names
194
- if other and other != field.get("name")
195
- and ("{" + str(other) + "}" in path or other in explicit)
196
- )
197
- if depends:
198
- field["dependsOn"] = depends
199
- reference.setdefault("dependsOn", depends)
200
- # Designer order where the connector states one, so a cascade reads
201
- # parent-first in every listing rather than in dict order.
202
- fields.sort(key=lambda f: (f.get("sortOrder") is None, f.get("sortOrder") or 0))
203
- return fields
204
-
205
-
206
- def camel_fields(fields: list | None) -> list[dict]:
207
- """Describe/objects fields normalized to the registry's camelCase shape.
208
-
209
- `triggers describe` answers PascalCase (`Name`/`Type`/`DisplayName`);
210
- the registry definition — and everything downstream that reads a v1def —
211
- is camelCase. One shape leaves this module.
212
- """
213
- out = []
214
- for f in fields or []:
215
- if not isinstance(f, dict):
216
- continue
217
- low = {k[0].lower() + k[1:] if k[:1].isupper() else k: v
218
- for k, v in f.items()}
219
- name = low.get("name")
220
- if not name:
221
- continue
222
- if _is_hidden(low):
223
- continue
224
- field = {"name": name}
225
- for k in ("displayName", "description", "required", "dataType",
226
- "sortOrder"):
227
- if low.get(k) is not None:
228
- field[k] = low[k]
229
- if low.get("reference") is not None:
230
- field["reference"] = _camelize(low["reference"])
231
- _keep_hidden_marker(low, field)
232
- # Describe's `Type` is the DATA type; an objects-row Parameter's
233
- # `type` is the BUCKET. The caller that knows which source it read
234
- # passes bucketed fields through `normalize_bucket_fields` instead.
235
- if low.get("type") is not None and "dataType" not in field:
236
- field["type"] = low["type"]
237
- field["dataType"] = low["type"]
238
- elif "dataType" in field:
239
- field["type"] = field["dataType"]
240
- out.append(field)
241
- return out
242
-
243
-
244
- def normalize_bucket_fields(parameters: list | None) -> list[dict]:
245
- """`triggers objects → Parameters[]` rows, camelCase, bucket made explicit.
246
-
247
- On these rows `type` selects the emit-time detail bucket (`query` →
248
- `queryParameters`, `path` → `pathParameters`, anything else → event
249
- parameters) and `dataType` is the value type — the OPPOSITE reading of
250
- `type` from every other field list, which is exactly the kind of
251
- quiet trap this normalization exists to bury.
252
- """
253
- out = []
254
- for f in parameters or []:
255
- if not isinstance(f, dict):
256
- continue
257
- low = {k[0].lower() + k[1:] if k[:1].isupper() else k: v
258
- for k, v in f.items()}
259
- name = low.get("name")
260
- if not name:
261
- continue
262
- if _is_hidden(low):
263
- continue
264
- field = {"name": name,
265
- "bucket": _BUCKETS.get(str(low.get("type") or "").lower(), "event")}
266
- for k in ("displayName", "description", "required", "sortOrder"):
267
- if low.get(k) is not None:
268
- field[k] = low[k]
269
- if low.get("reference") is not None:
270
- field["reference"] = _camelize(low["reference"])
271
- data_type = low.get("dataType") or "string"
272
- field["type"] = data_type
273
- field["dataType"] = data_type
274
- _keep_hidden_marker(low, field)
275
- out.append(field)
276
- return out
277
-
278
-
279
- def union_event_parameters(
280
- declared: list | None, object_parameters: list | None,
281
- ) -> list[dict]:
282
- """The V1 union rule: describe's EventParameters ∪ the object row's Parameters.
283
-
284
- Deduped by name; `required` is an OR — a field required on either side is
285
- required, because each side is authoritative for fields the other omits.
286
- The object row's bucket survives the merge; a field only describe knows
287
- is an event parameter by definition.
288
- """
289
- merged: dict[str, dict] = {}
290
- for field in camel_fields(declared):
291
- field.setdefault("bucket", "event")
292
- merged[field["name"]] = field
293
- for field in normalize_bucket_fields(object_parameters):
294
- existing = merged.get(field["name"])
295
- if existing is None:
296
- merged[field["name"]] = field
297
- continue
298
- existing["required"] = bool(existing.get("required")) or bool(field.get("required"))
299
- existing["bucket"] = field["bucket"]
300
- for k in ("displayName", "description", "reference", "sortOrder"):
301
- if field.get(k) is not None and existing.get(k) is None:
302
- existing[k] = field[k]
303
- return _with_cascade(list(merged.values()))
304
-
305
-
306
- def merge_describe_into_node(
307
- node: dict, describe: dict, object_parameters: list | None = None,
308
- ) -> dict:
309
- """Fold a `triggers describe` answer into a registry trigger definition.
310
-
311
- The registry definition IS the v1def the overlay ships, and
312
- `Library.eventParameterFields` already reads `v1def.eventParameters.fields`
313
- — so the deterministic store is "the definition, with the describe
314
- sections made true for this object" rather than a new format.
315
- """
316
- node = dict(node)
317
- node["eventParameters"] = {"fields": union_event_parameters(
318
- describe.get("EventParameters"), object_parameters,
319
- )}
320
- node["filterFields"] = {"fields": camel_fields(describe.get("FilterFields"))}
321
- if describe.get("OutputFields"):
322
- node["outputResponseDefinition"] = {
323
- "fields": camel_fields(describe.get("OutputFields"))}
324
- for src, dst in (("EventMode", "eventMode"),
325
- ("ByoaConnection", "byoaConnection"),
326
- ("IsWebhookUrlVisible", "isWebhookUrlVisible")):
327
- if describe.get(src) is not None:
328
- node[dst] = describe[src]
329
- return node
330
-
331
-
332
- def event_facts(node: dict, object_row: dict | None) -> dict:
333
- """The three facts that change how a subscription is prepared or tested.
334
-
335
- Per-object answers (the `triggers objects` row, or a describe) win over
336
- the definition's: `byoaConnection` and `isWebhookUrlVisible` are declared
337
- PER EVENT OBJECT, not per connector — V1 measured connectors requiring
338
- BYOA for some events and not others.
339
- """
340
- row = object_row or {}
341
- def _fact(row_key: str, node_key: str, default):
342
- if row.get(row_key) is not None:
343
- return row[row_key]
344
- if node.get(node_key) is not None:
345
- return node[node_key]
346
- return default
347
- return {
348
- "eventMode": str(node.get("eventMode") or "polling"),
349
- "byoaConnection": bool(_fact("ByoaConnection", "byoaConnection", False)),
350
- "isWebhookUrlVisible": bool(_fact("IsWebhookUrlVisible", "isWebhookUrlVisible", False)),
351
- "textBlocks": [
352
- str(t) for t in ((row.get("Design") or {}).get("TextBlocks") or
353
- (row.get("design") or {}).get("textBlocks") or [])
354
- ],
355
- }