@uipath/maestro-builder-sdk 5.2.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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/dist/bindings-v2.d.ts +37 -0
  4. package/dist/bindings-v2.js +155 -0
  5. package/dist/bindings.d.ts +6 -0
  6. package/dist/bindings.js +6 -0
  7. package/dist/bpmn/bpmn-expr-check.d.ts +41 -0
  8. package/dist/bpmn/bpmn-expr-check.js +218 -0
  9. package/dist/bpmn/bpmn-sdk.d.ts +2257 -0
  10. package/dist/bpmn/bpmn-sdk.js +1476 -0
  11. package/dist/bpmn/check-cli.d.ts +3 -0
  12. package/dist/bpmn/check-cli.js +32 -0
  13. package/dist/bpmn/check.d.ts +25 -0
  14. package/dist/bpmn/check.js +430 -0
  15. package/dist/bpmn/compile-cli.d.ts +3 -0
  16. package/dist/bpmn/compile-cli.js +60 -0
  17. package/dist/bpmn/connector.d.ts +56 -0
  18. package/dist/bpmn/connector.js +199 -0
  19. package/dist/bpmn/decompile-cli.d.ts +8 -0
  20. package/dist/bpmn/decompile-cli.js +54 -0
  21. package/dist/bpmn/decompile.d.ts +6 -0
  22. package/dist/bpmn/decompile.js +1108 -0
  23. package/dist/bpmn/format-profile.d.ts +27 -0
  24. package/dist/bpmn/format-profile.js +11 -0
  25. package/dist/bpmn/intsvc.d.ts +77 -0
  26. package/dist/bpmn/intsvc.js +155 -0
  27. package/dist/bpmn/load.d.ts +4 -0
  28. package/dist/bpmn/load.js +22 -0
  29. package/dist/bpmn/merge-cli.d.ts +3 -0
  30. package/dist/bpmn/merge-cli.js +45 -0
  31. package/dist/bpmn/merge.d.ts +19 -0
  32. package/dist/bpmn/merge.js +293 -0
  33. package/dist/bpmn/registry-types.json +1869 -0
  34. package/dist/bpmn/serialize.d.ts +28 -0
  35. package/dist/bpmn/serialize.js +757 -0
  36. package/dist/bpmn/tidy-cli.d.ts +2 -0
  37. package/dist/bpmn/tidy-cli.js +39 -0
  38. package/dist/bpmn/typed-node.d.ts +382 -0
  39. package/dist/bpmn/typed-node.js +551 -0
  40. package/dist/bpmn/uipath-moddle.v1.json +823 -0
  41. package/dist/bpmn/xml-ranges.d.ts +45 -0
  42. package/dist/bpmn/xml-ranges.js +151 -0
  43. package/dist/case/case-expr-check.d.ts +35 -0
  44. package/dist/case/case-expr-check.js +85 -0
  45. package/dist/case/case-sdk.d.ts +1282 -0
  46. package/dist/case/case-sdk.js +923 -0
  47. package/dist/case/check-cli.d.ts +3 -0
  48. package/dist/case/check-cli.js +31 -0
  49. package/dist/case/check.d.ts +18 -0
  50. package/dist/case/check.js +456 -0
  51. package/dist/case/compile-cli.d.ts +3 -0
  52. package/dist/case/compile-cli.js +291 -0
  53. package/dist/case/decompile-cli.d.ts +9 -0
  54. package/dist/case/decompile-cli.js +117 -0
  55. package/dist/case/decompile.d.ts +187 -0
  56. package/dist/case/decompile.js +1170 -0
  57. package/dist/case/entry-points.d.ts +79 -0
  58. package/dist/case/entry-points.js +118 -0
  59. package/dist/case/format-profile.d.ts +60 -0
  60. package/dist/case/format-profile.js +79 -0
  61. package/dist/case/ids.d.ts +24 -0
  62. package/dist/case/ids.js +76 -0
  63. package/dist/case/load.d.ts +12 -0
  64. package/dist/case/load.js +32 -0
  65. package/dist/case/preserve.d.ts +133 -0
  66. package/dist/case/preserve.js +105 -0
  67. package/dist/case/serialize.d.ts +10 -0
  68. package/dist/case/serialize.js +1414 -0
  69. package/dist/check-cli.d.ts +3 -0
  70. package/dist/check-cli.js +118 -0
  71. package/dist/check.d.ts +146 -0
  72. package/dist/check.js +4898 -0
  73. package/dist/cli/commands/bpmn.d.ts +2 -0
  74. package/dist/cli/commands/bpmn.js +73 -0
  75. package/dist/cli/commands/case.d.ts +2 -0
  76. package/dist/cli/commands/case.js +60 -0
  77. package/dist/cli/commands/flow.d.ts +2 -0
  78. package/dist/cli/commands/flow.js +99 -0
  79. package/dist/cli/commands/registry.d.ts +10 -0
  80. package/dist/cli/commands/registry.js +170 -0
  81. package/dist/cli/common.d.ts +30 -0
  82. package/dist/cli/common.js +92 -0
  83. package/dist/cli/index.d.ts +2 -0
  84. package/dist/cli/index.js +18 -0
  85. package/dist/cli-run.d.ts +2 -0
  86. package/dist/cli-run.js +42 -0
  87. package/dist/compile-cli.d.ts +3 -0
  88. package/dist/compile-cli.js +160 -0
  89. package/dist/config.d.ts +47 -0
  90. package/dist/config.js +201 -0
  91. package/dist/core/actions.d.ts +3244 -0
  92. package/dist/core/actions.js +1062 -0
  93. package/dist/core/binding-messages.d.ts +1 -0
  94. package/dist/core/binding-messages.js +16 -0
  95. package/dist/core/bindings.d.ts +12 -0
  96. package/dist/core/bindings.js +67 -0
  97. package/dist/core/ceql.d.ts +73 -0
  98. package/dist/core/ceql.js +272 -0
  99. package/dist/core/cli-spelling.d.ts +39 -0
  100. package/dist/core/cli-spelling.js +62 -0
  101. package/dist/core/connector-raw-node.d.ts +21 -0
  102. package/dist/core/connector-raw-node.js +47 -0
  103. package/dist/core/connectors.d.ts +184 -0
  104. package/dist/core/connectors.js +128 -0
  105. package/dist/core/expr-check.d.ts +93 -0
  106. package/dist/core/expr-check.js +186 -0
  107. package/dist/core/expr.d.ts +323 -0
  108. package/dist/core/expr.js +400 -0
  109. package/dist/core/index.d.ts +10 -0
  110. package/dist/core/index.js +10 -0
  111. package/dist/core/library.d.ts +440 -0
  112. package/dist/core/library.js +1183 -0
  113. package/dist/core/list-envelope.d.ts +45 -0
  114. package/dist/core/list-envelope.js +97 -0
  115. package/dist/core/lookup-store.d.ts +22 -0
  116. package/dist/core/lookup-store.js +85 -0
  117. package/dist/core/lookups.d.ts +227 -0
  118. package/dist/core/lookups.js +231 -0
  119. package/dist/core/node-classes.d.ts +234 -0
  120. package/dist/core/node-classes.js +158 -0
  121. package/dist/core/script-return.d.ts +68 -0
  122. package/dist/core/script-return.js +197 -0
  123. package/dist/core/stable-id.d.ts +28 -0
  124. package/dist/core/stable-id.js +37 -0
  125. package/dist/core-definitions.json +16249 -0
  126. package/dist/decompile-cli.d.ts +3 -0
  127. package/dist/decompile-cli.js +131 -0
  128. package/dist/decompile.d.ts +138 -0
  129. package/dist/decompile.js +2482 -0
  130. package/dist/emit.d.ts +10 -0
  131. package/dist/emit.js +60 -0
  132. package/dist/event-filters.d.ts +130 -0
  133. package/dist/event-filters.js +137 -0
  134. package/dist/flow-expr-check.d.ts +17 -0
  135. package/dist/flow-expr-check.js +193 -0
  136. package/dist/flow-sdk.d.ts +1341 -0
  137. package/dist/flow-sdk.js +1164 -0
  138. package/dist/flow-variables.d.ts +13 -0
  139. package/dist/flow-variables.js +42 -0
  140. package/dist/format-profile.d.ts +72 -0
  141. package/dist/format-profile.js +97 -0
  142. package/dist/generators/_connections.py +287 -0
  143. package/dist/generators/_events.py +355 -0
  144. package/dist/generators/_lookups.py +114 -0
  145. package/dist/generators/_naming.py +185 -0
  146. package/dist/generators/_objects.py +299 -0
  147. package/dist/generators/_resolve.py +273 -0
  148. package/dist/generators/convert_library_to_md.py +1441 -0
  149. package/dist/generators/extract_library.py +125 -0
  150. package/dist/generators/generate_connectors_ts.py +936 -0
  151. package/dist/generators/generate_library_json.py +2527 -0
  152. package/dist/generators/generate_triggers_ts.py +110 -0
  153. package/dist/generators/materialize_registry.py +155 -0
  154. package/dist/generators/prepare_connector.py +1688 -0
  155. package/dist/index.d.ts +35 -0
  156. package/dist/index.js +35 -0
  157. package/dist/ixp-definition.json +1982 -0
  158. package/dist/library.d.ts +6 -0
  159. package/dist/library.js +6 -0
  160. package/dist/load.d.ts +16 -0
  161. package/dist/load.js +95 -0
  162. package/dist/merge-cli.d.ts +3 -0
  163. package/dist/merge-cli.js +47 -0
  164. package/dist/merge.d.ts +125 -0
  165. package/dist/merge.js +0 -0
  166. package/dist/migrate.d.ts +15 -0
  167. package/dist/migrate.js +245 -0
  168. package/dist/node-runtime.d.ts +3 -0
  169. package/dist/node-runtime.js +22 -0
  170. package/dist/package-name.d.ts +17 -0
  171. package/dist/package-name.js +17 -0
  172. package/dist/ref-resolve.d.ts +77 -0
  173. package/dist/ref-resolve.js +170 -0
  174. package/dist/registry/cache.d.ts +55 -0
  175. package/dist/registry/cache.js +134 -0
  176. package/dist/registry/curated-overlay.d.ts +16 -0
  177. package/dist/registry/curated-overlay.js +62 -0
  178. package/dist/registry/extractor.d.ts +22 -0
  179. package/dist/registry/extractor.js +18 -0
  180. package/dist/registry/materialize-bin.d.ts +2 -0
  181. package/dist/registry/materialize-bin.js +7 -0
  182. package/dist/registry/materialize-cli.d.ts +1 -0
  183. package/dist/registry/materialize-cli.js +46 -0
  184. package/dist/registry/materializer.d.ts +23 -0
  185. package/dist/registry/materializer.js +34 -0
  186. package/dist/registry/path-cli.d.ts +1 -0
  187. package/dist/registry/path-cli.js +56 -0
  188. package/dist/registry/prepare-cli.d.ts +62 -0
  189. package/dist/registry/prepare-cli.js +185 -0
  190. package/dist/registry/pull-cli.d.ts +1 -0
  191. package/dist/registry/pull-cli.js +274 -0
  192. package/dist/registry/python.d.ts +26 -0
  193. package/dist/registry/python.js +46 -0
  194. package/dist/registry/search-cli.d.ts +52 -0
  195. package/dist/registry/search-cli.js +159 -0
  196. package/dist/schema-version.d.ts +165 -0
  197. package/dist/schema-version.js +148 -0
  198. package/dist/serialize.d.ts +64 -0
  199. package/dist/serialize.js +6398 -0
  200. package/lib/README.md +112 -0
  201. package/lib/library-json.samples/index.json +158 -0
  202. package/lib/library-json.samples/invariant-collections.json +83 -0
  203. package/lib/library-json.samples/list-envelopes.json +123 -0
  204. package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.json +131 -0
  205. package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.v1def.json +306 -0
  206. package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.json +86 -0
  207. package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.v1def.json +208 -0
  208. package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.json +2040 -0
  209. package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.v1def.json +195 -0
  210. package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.json +169 -0
  211. package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.v1def.json +209 -0
  212. package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.json +63 -0
  213. package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.v1def.json +262 -0
  214. package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.json +63 -0
  215. package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.v1def.json +213 -0
  216. package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.json +227 -0
  217. package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.v1def.json +373 -0
  218. package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.json +116 -0
  219. package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.v1def.json +369 -0
  220. package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.json +56 -0
  221. package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.v1def.json +256 -0
  222. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.json +116 -0
  223. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.v1def.json +320 -0
  224. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.json +56 -0
  225. package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.v1def.json +207 -0
  226. package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.json +107 -0
  227. package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.v1def.json +264 -0
  228. package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.json +142 -0
  229. package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.v1def.json +306 -0
  230. package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.json +107 -0
  231. package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.v1def.json +217 -0
  232. package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.json +85 -0
  233. package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.v1def.json +209 -0
  234. package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.json +755 -0
  235. package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.v1def.json +513 -0
  236. package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.json +122 -0
  237. package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.v1def.json +227 -0
  238. package/package.json +85 -0
@@ -0,0 +1,2527 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate the canonical connector library from the UiPath Flow registry.
4
+
5
+ For each enabled, cross-platform, non-experimental connector node type this
6
+ script writes a single JSON file under `--output-dir` (default: `./library/`)
7
+ with the shape below. The output is language-neutral and round-trips with
8
+ Flow definitions: every field a Flow node needs is either in this file
9
+ or derivable from a flow's per-node manifest.
10
+
11
+ library/
12
+ <connector-key>/
13
+ <action-id>@<version>.json # curated, e.g. create-issue@1.0.0.json
14
+ <action-id>@<version>.v1def.json # Flow definitions[] sidecar
15
+ generic/
16
+ <object>/
17
+ <op>@<version>.json # Generic CRUD expanded per
18
+ <op>@<version>.v1def.json # (object, operation) pair
19
+ ...
20
+ index.json # flat list of every entry, for tooling
21
+
22
+ Per-entry shape:
23
+
24
+ {
25
+ "schemaVersion": "1",
26
+ "nodeType": "uipath.connector.uipath-microsoft-github.create-issue",
27
+ "version": "1.0.0",
28
+ "category": "...",
29
+ "tags": ["connector"],
30
+ "connector": { "key": "uipath-microsoft-github" },
31
+ "operation": {
32
+ "name": "Create",
33
+ "objectName": "create_issues",
34
+ "httpMethod": "POST",
35
+ "subType": "standard",
36
+ "supportsStreaming": false
37
+ },
38
+ "display": { "label": "...", "description": "...", "icon": "...",
39
+ "iconBackground": "...", "iconBackgroundDark": "..." },
40
+ "runtime": {
41
+ "bpmnType": "bpmn:SendTask",
42
+ "serviceType": "Intsvc.ActivityExecution",
43
+ "activityConfigurationVersion": "1.0.0",
44
+ "requiresConnection": true,
45
+ "requiresFolderKey": true
46
+ },
47
+ "inputSchema": { "fields": [ ... raw registry shape ... ] },
48
+ "outputSchema": { "fields": [ ... ] }
49
+ }
50
+
51
+ The script keeps the CLI plumbing (registry search, parallel get, on-disk
52
+ cache, resume support) from the cs2fil ancestor unchanged — only the
53
+ extraction and write phases differ.
54
+
55
+ Usage:
56
+ python3 generate_library_json.py [options]
57
+
58
+ Options:
59
+ --output-dir PATH Output directory (default: ./library)
60
+ --cache-dir PATH Cache for raw `flow registry get` JSONs
61
+ (default: ./.registry-cache)
62
+ --is-cache-dir PATH Cache for raw `is resources describe` JSONs
63
+ (default: ./.is-cache)
64
+ --skip-enrichment Skip the second pass that calls
65
+ `uip is resources describe` per entry
66
+ --keep-temp Keep cache directories after run
67
+ --catalog-scope SCOPE Connector catalog scope: all, tier1, or tier2
68
+ (default: all). Tiers are resolved live from
69
+ `uip is connectors metadata <connector-key>`.
70
+ --concurrency N Parallel remote CLI calls (default: 10)
71
+ """
72
+
73
+ import argparse
74
+ import hashlib
75
+ import json
76
+ import os
77
+ import re
78
+ import shutil
79
+ import subprocess
80
+ import sys
81
+ from concurrent.futures import ThreadPoolExecutor, as_completed
82
+
83
+
84
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
85
+ import _events # noqa: E402
86
+
87
+ SCHEMA_VERSION = "1"
88
+
89
+ # When set (by prepare_connector's single-op, connection-scoped path), every
90
+ # `uip flow registry get` / `is resources describe` / `is resources list` call
91
+ # appends `--connection-id <id>`. The bulk generator leaves this None (it runs
92
+ # connection-less, yielding the thin schema-dynamic entries C4 later overlays).
93
+ _CONNECTION_ID = None
94
+
95
+
96
+ def set_connection_id(connection_id):
97
+ """Route all subsequent uip calls through this connection (see C4 / §E)."""
98
+ global _CONNECTION_ID
99
+ _CONNECTION_ID = connection_id
100
+
101
+
102
+ def _conn_args():
103
+ """`['--connection-id', <id>]` when a connection is set, else `[]`."""
104
+ return ["--connection-id", _CONNECTION_ID] if _CONNECTION_ID else []
105
+
106
+
107
+ _PARENT_FIELDS = {}
108
+
109
+
110
+ def set_parent_fields(fields):
111
+ """Parent-field VALUES forwarded to `is resources describe -f name=value`.
112
+
113
+ A schema-dynamic operation resolves its real field set from the values of a
114
+ few parent fields, not from the connection alone. Jira `create-issue`, live
115
+ against one tenant: no `-f` describes to 2 fields (the parents themselves);
116
+ project `IN` + issue type Task describes to 19; the same project with Epic
117
+ describes to a DIFFERENT 22; a different project with its own Task describes
118
+ to 15. Both dimensions matter independently.
119
+
120
+ Too few parents is refused outright ("No api-type ObjectAction matched for
121
+ fields [...]"); a complete set carrying a value the connection does not have
122
+ is accepted and resolves back to just the parents. See
123
+ `failed_field_describes` for why the first needs carrying out of band.
124
+ """
125
+ global _PARENT_FIELDS
126
+ _PARENT_FIELDS = dict(fields or {})
127
+
128
+
129
+ def _field_args():
130
+ """`['-f', 'name=value', ...]` for the parent fields set, else `[]`."""
131
+ args = []
132
+ for name, value in _PARENT_FIELDS.items():
133
+ args += ["-f", f"{name}={value}"]
134
+ return args
135
+
136
+
137
+ _FAILED_FIELD_DESCRIBES = []
138
+
139
+
140
+ def reset_failed_field_describes():
141
+ """Clear the record of describes that failed while parent fields were set."""
142
+ del _FAILED_FIELD_DESCRIBES[:]
143
+
144
+
145
+ def failed_field_describes():
146
+ """The CLI's own `Message` for each describe that failed under `-f`.
147
+
148
+ Recorded only while parent fields are set, because that is when a failure is
149
+ diagnostic rather than ordinary catalog noise. An incomplete parent set is
150
+ rejected outright and helpfully:
151
+
152
+ Result: Failure
153
+ Message: No api-type ObjectAction matched for fields
154
+ [fields.project.key] on operation 'Create'
155
+
156
+ but the pipeline swallows it — `is_describe_operation` maps any non-Success
157
+ to `None`, and `enrich_entry` then returns the entry with its registry-
158
+ DECLARED fields intact. The caller sees a plausible small schema and no
159
+ error at all, so the good message has to be carried out of band.
160
+ """
161
+ return list(_FAILED_FIELD_DESCRIBES)
162
+
163
+
164
+ def _note_failed_field_describe(connector_key, object_name, operation, message):
165
+ if _PARENT_FIELDS:
166
+ _FAILED_FIELD_DESCRIBES.append(
167
+ (connector_key, object_name, operation, message or "")
168
+ )
169
+
170
+
171
+ def _field_scope():
172
+ """Cache discriminator for the parent-field set (empty when none is set).
173
+
174
+ The describe response is a function of these values, so a cache keyed only
175
+ on (connection, object, operation) would serve the first variant's fields
176
+ for every later one — the whole point of preparing per issue type.
177
+ """
178
+ if not _PARENT_FIELDS:
179
+ return ""
180
+ flat = "\0".join(f"{k}={v}" for k, v in sorted(_PARENT_FIELDS.items()))
181
+ return "f-" + hashlib.sha256(flat.encode()).hexdigest()[:12]
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # CLI
186
+ # ---------------------------------------------------------------------------
187
+
188
+ def parse_args():
189
+ p = argparse.ArgumentParser(
190
+ description="Generate the canonical connector library from the UiPath Flow registry"
191
+ )
192
+ p.add_argument("--output-dir", default="./library",
193
+ help="Output directory (default: ./library)")
194
+ p.add_argument("--cache-dir", default="./.registry-cache",
195
+ help="Cache for `flow registry get` JSONs (default: ./.registry-cache)")
196
+ p.add_argument("--is-cache-dir", default="./.is-cache",
197
+ help="Cache for `is resources describe` JSONs (default: ./.is-cache)")
198
+ p.add_argument("--connectors-cache-dir", default="./.is-connectors-cache",
199
+ help="Cache for `is connectors metadata` JSONs "
200
+ "(default: ./.is-connectors-cache)")
201
+ p.add_argument("--skip-enrichment", action="store_true",
202
+ help="Skip the `is resources describe` enrichment pass")
203
+ p.add_argument("--skip-connector-enrichment", action="store_true",
204
+ help="Skip the `is connectors metadata` connector-level pass")
205
+ p.add_argument("--skip-generic-expansion", action="store_true",
206
+ help="Skip the Generic CRUD expansion pass (keeps Generic CRUD "
207
+ "entries as un-expanded templates instead of materializing "
208
+ "one entry per object/operation)")
209
+ p.add_argument("--triggers-output", default=None,
210
+ help="Path for the trigger registry side-file (default: "
211
+ "<parent of --output-dir>/registry-triggers.json). "
212
+ "This is a catalog of every IS trigger node type "
213
+ "found by `flow registry search`. The START form is "
214
+ "not materialized into the main library because "
215
+ "`flow registry get <trigger>` requires "
216
+ "--connection-id, which would gate the full library "
217
+ "build on the user having a connection for every "
218
+ "trigger-supporting connector; its WAIT twin "
219
+ "(`uipath.connector.event.<key>.<event>`) answers "
220
+ "offline and IS generated, and the SDK derives the "
221
+ "start form from it.")
222
+ p.add_argument("--keep-temp", action="store_true",
223
+ help="Keep cache directories after run")
224
+ p.add_argument("--connectors", default=None,
225
+ help="Comma-separated connector keys to include (e.g. "
226
+ "'uipath-salesforce-slack,uipath-atlassian-jira'). When set, "
227
+ "only these connectors' action node types are enriched + "
228
+ "materialized — a CURATED subset of the full registry. Avoids "
229
+ "the cost of enriching all ~149 connectors. Default: all.")
230
+ p.add_argument("--catalog-scope", choices=("all", "tier1", "tier2"),
231
+ default="all",
232
+ help="Connector catalog scope. 'tier1' and 'tier2' select "
233
+ "connector keys whose live `uip is connectors metadata` "
234
+ "Tier field is '1' or '2'; 'all' keeps the complete "
235
+ "catalog (default: all).")
236
+ p.add_argument("--concurrency", type=int, default=10,
237
+ help="Parallel remote CLI calls (default: 10)")
238
+ return p.parse_args()
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Registry interaction
243
+ # ---------------------------------------------------------------------------
244
+
245
+ def registry_search(cache_dir):
246
+ """Run uip flow registry search and return list of connector node types."""
247
+ search_file = os.path.join(cache_dir, "_search_results.json")
248
+ if not os.path.exists(search_file):
249
+ print("Running registry search...")
250
+ result = subprocess.run(
251
+ ["uip", "maestro", "flow", "registry", "search",
252
+ "--filter", "tags:in=connector", "--output", "json"],
253
+ capture_output=True, text=True
254
+ )
255
+ if result.returncode != 0:
256
+ print(f"Registry search failed: {result.stderr}", file=sys.stderr)
257
+ sys.exit(1)
258
+ os.makedirs(cache_dir, exist_ok=True)
259
+ with open(search_file, "w") as f:
260
+ f.write(result.stdout)
261
+
262
+ with open(search_file) as f:
263
+ data = json.load(f)
264
+
265
+ return [
266
+ entry["NodeType"]
267
+ for entry in data.get("Data", [])
268
+ if entry["NodeType"].startswith("uipath.connector.")
269
+ ]
270
+
271
+
272
+ def write_triggers_side_file(cache_dir, output_path):
273
+ """Write a flat catalog of every IS trigger nodeType in the registry.
274
+
275
+ Triggers can't be materialized into the main library because
276
+ `uip flow registry get <trigger>` requires `--connection-id`, and the
277
+ library generator can't know which connection to use for the ~70
278
+ different trigger-supporting connectors. The registry SEARCH does list
279
+ every trigger nodeType with name + description + version + tags
280
+ (connection-less), so we capture those into a side-file. Downstream
281
+ tools (the emitter, the dispatcher, the n8n classification) consult this
282
+ file for trigger existence/lookup; full per-trigger schema fetch is
283
+ deferred to author time (when the user has a connection).
284
+ """
285
+ search_file = os.path.join(cache_dir, "_search_results.json")
286
+ if not os.path.exists(search_file):
287
+ print(f" WARN: search cache missing at {search_file}; "
288
+ f"can't write triggers side-file", file=sys.stderr)
289
+ return 0
290
+
291
+ with open(search_file) as f:
292
+ data = json.load(f)
293
+
294
+ triggers = []
295
+ for entry in data.get("Data", []) or []:
296
+ node_type = entry.get("NodeType", "")
297
+ if not node_type.startswith("uipath.connector.trigger."):
298
+ continue
299
+ parts = node_type.split(".")
300
+ connector_key = parts[3] if len(parts) > 3 else ""
301
+ triggers.append({
302
+ "NodeType": node_type,
303
+ "DisplayName": entry.get("DisplayName", ""),
304
+ "Description": entry.get("Description", ""),
305
+ "Version": entry.get("Version", ""),
306
+ "connectorKey": connector_key,
307
+ })
308
+
309
+ triggers.sort(key=lambda t: (t["connectorKey"], t["NodeType"]))
310
+
311
+ envelope = {
312
+ "schemaVersion": "1",
313
+ "note": (
314
+ "Catalog of every IS trigger nodeType returned by "
315
+ "`uip maestro flow registry search --filter tags:in=connector`. "
316
+ "Full per-trigger schemas (filterFields, outputFields, eventMode) "
317
+ "require `--connection-id` on `uip flow registry get`, which "
318
+ "the library generator can't supply across all ~70 "
319
+ "trigger-supporting connectors. Use this side-file for the "
320
+ "'does an IS trigger exist for X provider?' question; fetch the "
321
+ "full schema at author time with `uip is triggers describe "
322
+ "<connector-key> <op> <object> --connection-id <id>`."
323
+ ),
324
+ "count": len(triggers),
325
+ "triggers": triggers,
326
+ }
327
+
328
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
329
+ with open(output_path, "w") as f:
330
+ json.dump(envelope, f, indent=2)
331
+ f.write("\n")
332
+ print(f" wrote {len(triggers)} triggers to {output_path}")
333
+ return len(triggers)
334
+
335
+
336
+ def registry_get(node_type, cache_dir, quiet=False):
337
+ """Fetch a single connector's JSON, using cache if available.
338
+
339
+ `quiet` suppresses the failure WARN — for a caller probing which of
340
+ several node-type namespaces an action lives in, where a miss is an
341
+ expected answer rather than a problem to report.
342
+ """
343
+ safe_name = node_type.replace(".", "_")
344
+ cache_file = os.path.join(cache_dir, f"{safe_name}.json")
345
+ if os.path.exists(cache_file):
346
+ return cache_file
347
+
348
+ result = subprocess.run(
349
+ ["uip", "maestro", "flow", "registry", "get", node_type, "--output", "json"] + _conn_args(),
350
+ capture_output=True, text=True
351
+ )
352
+ if result.returncode != 0:
353
+ if not quiet:
354
+ print(f" WARN: registry get failed for {node_type}: {result.stderr.strip()}",
355
+ file=sys.stderr)
356
+ return None
357
+
358
+ with open(cache_file, "w") as f:
359
+ f.write(result.stdout)
360
+ return cache_file
361
+
362
+
363
+ def fetch_all(node_types, cache_dir, concurrency):
364
+ """Fetch all connector JSONs in parallel."""
365
+ os.makedirs(cache_dir, exist_ok=True)
366
+ results = {}
367
+ already_cached = 0
368
+ to_fetch = []
369
+
370
+ for nt in node_types:
371
+ safe_name = nt.replace(".", "_")
372
+ cache_file = os.path.join(cache_dir, f"{safe_name}.json")
373
+ if os.path.exists(cache_file):
374
+ results[nt] = cache_file
375
+ already_cached += 1
376
+ else:
377
+ to_fetch.append(nt)
378
+
379
+ if already_cached:
380
+ print(f" {already_cached} connectors already cached, {len(to_fetch)} to fetch")
381
+
382
+ if to_fetch:
383
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
384
+ futures = {pool.submit(registry_get, nt, cache_dir): nt for nt in to_fetch}
385
+ done = 0
386
+ for future in as_completed(futures):
387
+ nt = futures[future]
388
+ done += 1
389
+ path = future.result()
390
+ if path:
391
+ results[nt] = path
392
+ if done % 50 == 0 or done == len(to_fetch):
393
+ print(f" Fetched {done}/{len(to_fetch)}")
394
+
395
+ return results
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Canonical extraction
400
+ # ---------------------------------------------------------------------------
401
+ # Connector entries are kept if they're enabled, cross-platform, and not
402
+ # experimental. We no longer filter on `activityType == "Curated"` — Generic
403
+ # CRUD actions and HTTP escape hatches are kept too. Curated entries land
404
+ # at the connector top level unchanged; Generic CRUD entries are later
405
+ # materialized into per-(object, operation) entries under
406
+ # `<connector>/generic/<object>/<op>@<ver>.json` by `expand_all_generics`.
407
+
408
+ # Ops the IS CLI exposes as a POST whose inputs are query-string params with a
409
+ # required (often empty) body. A dispatcher reads `operation.dispatchStyle == "query-post"`
410
+ # and dispatches `run create <obj> --query <inputs> --body '{}'` instead of the
411
+ # default create→--body. Keyed by (connectorKey, objectName). The registry does not
412
+ # expose per-parameter location, so this is a small explicit list rather than derived.
413
+ CURATED_DISPATCH_STYLE = {
414
+ ("uipath-uipath-dataservice", "QueryEntityRecords_V3"): "query-post",
415
+ # The V2 curated sibling, and the one the REGISTRY node
416
+ # `uipath.connector.uipath-uipath-dataservice.query-entity-records` maps to:
417
+ # POST /v2/{entityName}/qer, every input a path or query parameter, body `{}`.
418
+ # Verified live 2026-07-31 — `run create … --query '{entityName,queryExpression,
419
+ # limit}' --body '{}'` returns the 8 BillingDisputeERP rows.
420
+ ("uipath-uipath-dataservice", "QueryEntityRecordsCurated"): "query-post",
421
+ }
422
+
423
+ # Per-parameter value-grammar tags, keyed by (connectorKey, objectName). A dispatcher
424
+ # validates the COMPUTED input against `operation.paramGrammars` at every
425
+ # dispatch boundary — fixture replay included, so `flow-cli test` catches a
426
+ # malformed value offline. "sql-where": Data Service query expressions (RHS
427
+ # string literals must be single-quoted; an unquoted `ACCT-98201-NE` parses as
428
+ # subtraction server-side and 400s). Explicit list like dispatchStyle — the
429
+ # registry doesn't describe value grammars.
430
+ CURATED_PARAM_GRAMMARS = {
431
+ ("uipath-uipath-dataservice", "QueryEntityRecords_V3"): {"queryExpression": "sql-where"},
432
+ ("uipath-uipath-dataservice", "QueryEntityRecords"): {"queryExpression": "sql-where"},
433
+ ("uipath-uipath-dataservice", "QueryEntityRecordsCurated"): {"queryExpression": "sql-where"},
434
+ }
435
+
436
+ # A few connector ops the registry enumerates only in a form `uip is resources run`
437
+ # can't dispatch. The Outlook reply op is enumerated as the MULTIPART `ReplyToEmailV2`
438
+ # (its `body`/`file` are `multipart` params, which a JSON `--body` can't build). The
439
+ # connector also exposes the non-multipart sibling `ReplyToEmail` (`id` in --query, the
440
+ # reply text in --body) that Flow v1 uses and a dispatcher sends via the per-parameter
441
+ # arg split. We curate a remap to that sibling so a clean regen reproduces the
442
+ # live-runnable op rather than the undispatchable multipart one. Keyed by (connectorKey,
443
+ # registry objectName); deep-merged into `operation` AFTER enrichment so it wins over the
444
+ # `is resources describe` params. The node's `inputSchema` (the reply `comment` + message
445
+ # fields) is left as enrichment derives it — only the query params + objectName/path change.
446
+ CURATED_OP_OVERRIDE = {
447
+ ("uipath-microsoft-outlook365", "ReplyToEmailV2"): {
448
+ "operation": {
449
+ "objectName": "ReplyToEmail",
450
+ "path": "/ReplyToEmail",
451
+ "parameters": [
452
+ {"name": "id", "type": "query", "dataType": "string", "required": True,
453
+ "displayName": "Message ID",
454
+ "description": "The id of the email to reply to"},
455
+ ],
456
+ },
457
+ },
458
+ }
459
+
460
+
461
+ def apply_curated_overrides(entries):
462
+ """Deep-merge CURATED_OP_OVERRIDE into matching entries, AFTER enrichment, so the
463
+ curated shape wins over the registry-described one. Matches on (connectorKey,
464
+ objectName) using the entry's current (registry) objectName."""
465
+ n = 0
466
+ for e in entries:
467
+ key = (e.get("connector", {}).get("key", ""),
468
+ e.get("operation", {}).get("objectName", ""))
469
+ override = CURATED_OP_OVERRIDE.get(key)
470
+ if not override:
471
+ continue
472
+ for section, fields in override.items():
473
+ e.setdefault(section, {})
474
+ for k, v in fields.items():
475
+ e[section][k] = v
476
+ n += 1
477
+ if n:
478
+ print(f" Applied {n} curated op override(s)")
479
+ return entries
480
+
481
+
482
+ def _context_value(context, name):
483
+ """Return the `value` of a context entry by name, or None."""
484
+ for c in context:
485
+ if c.get("name") == name:
486
+ return c.get("value")
487
+ return None
488
+
489
+
490
+ def _has_context(context, name):
491
+ return any(c.get("name") == name for c in context)
492
+
493
+
494
+ def _parameter_metadata(connector_method_info):
495
+ """Field-shaped metadata carried by each method parameter, keyed by name.
496
+
497
+ A method parameter and a node field describe the same input in two
498
+ different schemas. These are the keys that exist on both sides, so they
499
+ are the only ones worth moving across: `format`, `mask`, `design`,
500
+ `sortOrder`, `curated`, `defaultValue` and `experimental` have no
501
+ counterpart on a field, and a node's own field vocabulary never carries
502
+ them.
503
+
504
+ `reference` is the one that matters most — it names the collection a value
505
+ is looked up in (a Slack channel, a Drive file), and without it an author
506
+ has no way to turn a display name into the id the API wants.
507
+ """
508
+ out = {}
509
+ for p in (connector_method_info or {}).get("parameters") or []:
510
+ name = p.get("name")
511
+ if not isinstance(name, str) or not name:
512
+ continue
513
+ meta = {}
514
+ if p.get("displayName") is not None:
515
+ meta["displayName"] = p["displayName"]
516
+ if p.get("reference") is not None:
517
+ meta["reference"] = p["reference"]
518
+ # A parameter's `enum` is deprecated in favour of `enhancedEnum`, whose
519
+ # `{name, value}` pairs flatten to the literals a field's `enum` holds.
520
+ if "enum" in p:
521
+ meta["enum"] = p["enum"]
522
+ elif p.get("enhancedEnum"):
523
+ values = [
524
+ e.get("value")
525
+ for e in p["enhancedEnum"]
526
+ if isinstance(e, dict) and e.get("value") is not None
527
+ ]
528
+ if values:
529
+ meta["enum"] = values
530
+ if meta:
531
+ out[name] = meta
532
+ return out
533
+
534
+
535
+ def extract_canonical(json_path):
536
+ """Build a canonical-library entry from one raw registry JSON file.
537
+
538
+ Returns the entry dict, or None if the connector is filtered out
539
+ (not enabled, not curated, etc.).
540
+ """
541
+ with open(json_path) as f:
542
+ data = json.load(f)
543
+
544
+ node = data.get("Data", {}).get("Node")
545
+ if not node:
546
+ return None
547
+
548
+ node_type = node.get("nodeType", "")
549
+ model = node.get("model", {}) or {}
550
+ context = model.get("context", []) or []
551
+
552
+ # Activity-level metadata moved out of model.context.metadata into the
553
+ # editor form. Search `node.form.sections[*].fields[*].componentProps`
554
+ # for the `connectorDetail` block — it carries isEnabled / targetPlatform
555
+ # / isExperimental / configuration (a JSON string with activityType etc).
556
+ connector_detail = None
557
+ for section in (node.get("form", {}) or {}).get("sections", []) or []:
558
+ for f in section.get("fields", []) or []:
559
+ cp = (f.get("componentProps") or {}).get("connectorDetail")
560
+ if isinstance(cp, dict) and "configuration" in cp:
561
+ connector_detail = cp
562
+ break
563
+ if connector_detail:
564
+ break
565
+
566
+ if not connector_detail:
567
+ return None
568
+
569
+ if not connector_detail.get("isEnabled", False):
570
+ return None
571
+ if connector_detail.get("targetPlatform") != "CrossPlatform":
572
+ return None
573
+ if connector_detail.get("isExperimental", True):
574
+ return None
575
+
576
+ try:
577
+ config = json.loads(connector_detail.get("configuration", "{}"))
578
+ except json.JSONDecodeError:
579
+ return None
580
+
581
+ activity_type = config.get("activityType", "")
582
+
583
+ # Operation name — the human-readable verb (Create/Retrieve/etc.) lives
584
+ # on connectorMethodInfo when present; fall back to the model.context
585
+ # entry, then to the connectorDetail.configuration blob (Generic CRUD
586
+ # actions carry it only there).
587
+ cmi = node.get("connectorMethodInfo", {}) or {}
588
+ operation_name = (
589
+ cmi.get("operation")
590
+ or _context_value(context, "operation")
591
+ or config.get("operation")
592
+ or ""
593
+ )
594
+
595
+ # API endpoint path. `connectorMethodInfo.path` is in the un-enriched
596
+ # registry response (no connection-id needed) and matches the
597
+ # `inputs.detail.endpoint` field in Flow nodes — see uipath-maestro-flow
598
+ # connector plugin docs.
599
+ path = cmi.get("path") or ""
600
+
601
+ # Operation/object display name — what the editor uses for "objectDisplayName"
602
+ # in the Flow configuration blob. Lives under `connectorMethodInfo.curated`
603
+ # when present; we fall back to the top-level `display.label`.
604
+ curated = cmi.get("curated") or {}
605
+ object_display_name = curated.get("displayName") or ""
606
+
607
+ # Input fields. The registry describes an operation's inputs in two places
608
+ # and neither is reliably complete: `inputDefinition.fields` can be missing
609
+ # outright, or present but stripped down to name/type/required/description
610
+ # while `connectorMethodInfo.parameters` carries the lookup and the choice
611
+ # list for the very same input. So take the fields when they exist and fill
612
+ # their gaps from the parameters, and build them from the parameters when
613
+ # they do not. Gaps only: a value the node already states wins, because
614
+ # that is the node's own view of itself.
615
+ param_meta = _parameter_metadata(cmi)
616
+ declared_fields = node.get("inputDefinition", {}).get("fields") or []
617
+ input_fields = []
618
+ if declared_fields:
619
+ for declared in declared_fields:
620
+ merged = dict(declared)
621
+ for key, value in param_meta.get(merged.get("name"), {}).items():
622
+ merged.setdefault(key, value)
623
+ input_fields.append(merged)
624
+ else:
625
+ for p in cmi.get("parameters", []) or []:
626
+ f = {
627
+ "name": p.get("name", ""),
628
+ "type": p.get("dataType", "string"),
629
+ "required": p.get("required", False),
630
+ "description": p.get("description", ""),
631
+ }
632
+ f.update(param_meta.get(f["name"], {}))
633
+ input_fields.append(f)
634
+
635
+ output_fields = (
636
+ node.get("outputDefinition", {}).get("fields")
637
+ or node.get("outputResponseDefinition", {}).get("fields")
638
+ or []
639
+ )
640
+
641
+ display = node.get("display", {}) or {}
642
+
643
+ entry = {
644
+ "schemaVersion": SCHEMA_VERSION,
645
+ "nodeType": node_type,
646
+ "version": config.get("version", "") or node.get("version", ""),
647
+ "category": node.get("category", ""),
648
+ "tags": node.get("tags", []),
649
+ "connector": {
650
+ "key": config.get("connectorKey", ""),
651
+ },
652
+ "operation": {
653
+ "name": operation_name,
654
+ "objectName": config.get("objectName", ""),
655
+ "objectDisplayName": object_display_name,
656
+ "httpMethod": config.get("httpMethod", ""),
657
+ "path": path,
658
+ "subType": config.get("subType", ""),
659
+ "supportsStreaming": config.get("supportsStreaming", False),
660
+ "activityType": activity_type,
661
+ # A connector EVENT's operation is not `operation.name` — that is
662
+ # the object's CRUD verb (`Create` on outlook `email-received`).
663
+ # The event's own operation is the `model.context` entry the
664
+ # platform stamps (`EMAIL_RECEIVED`), and it is the key every
665
+ # trigger API takes: `is triggers objects/describe <key>
666
+ # <OPERATION>`. The SDK compares it too (`identifyEvent` reads
667
+ # `operation.eventOperation`), so carry it on the entry rather
668
+ # than making every reader dig through the sidecar's context.
669
+ **({"eventOperation": _context_value(context, "operation") or ""}
670
+ if str(activity_type).endswith("Trigger") else {}),
671
+ **({"eventMode": node.get("eventMode") or ""}
672
+ if str(activity_type).endswith("Trigger") and node.get("eventMode")
673
+ else {}),
674
+ },
675
+ "display": {
676
+ "label": display.get("label", "") or node.get("displayName", ""),
677
+ "description": display.get("description", "") or node.get("description", ""),
678
+ "icon": display.get("icon", ""),
679
+ "iconBackground": display.get("iconBackground", ""),
680
+ "iconBackgroundDark": display.get("iconBackgroundDark", ""),
681
+ },
682
+ "runtime": {
683
+ "bpmnType": model.get("type", ""),
684
+ "serviceType": model.get("serviceType", ""),
685
+ "activityConfigurationVersion": _context_value(context, "activityConfigurationVersion") or "",
686
+ "requiresConnection": _has_context(context, "connection"),
687
+ "requiresFolderKey": _has_context(context, "folderKey"),
688
+ # Connector discovery metadata replaces the old empty-schema
689
+ # heuristic later in merge_connector_record().
690
+ "requiresConnectionForSchema": False,
691
+ },
692
+ "inputSchema": {"fields": input_fields},
693
+ "outputSchema": {"fields": output_fields},
694
+ }
695
+ # Some operations resolve their real field set from the VALUES of a few
696
+ # parent fields, so what is above is only what holds before any are known.
697
+ # Record what a caller has to do about it; without this the catalog reads
698
+ # as though Jira create-issue takes two inputs.
699
+ action = schema_action(cmi, input_fields)
700
+ if action:
701
+ entry["operation"]["schemaAction"] = action
702
+ # Curated dispatch-style override: a few ops the IS CLI exposes as a POST whose
703
+ # inputs are query-string params with a required (empty) body — a dispatcher must
704
+ # dispatch them as `run create <obj> --query <inputs> --body '{}'`, not the
705
+ # default create→--body. (Data Service `QueryEntityRecords_V3`:
706
+ # POST /v3/QueryEntityRecords/query, params entityScope/tenantEntityName/
707
+ # queryExpression in --query.)
708
+ _dispatch_style = CURATED_DISPATCH_STYLE.get(
709
+ (config.get("connectorKey", ""), config.get("objectName", ""))
710
+ )
711
+ if _dispatch_style:
712
+ entry["operation"]["dispatchStyle"] = _dispatch_style
713
+ # Curated per-parameter grammar tags (see CURATED_PARAM_GRAMMARS): keeps a
714
+ # clean library regen carrying the same validation contract the committed
715
+ # curated defs ship, so the dispatch-input gate survives restaging.
716
+ _param_grammars = CURATED_PARAM_GRAMMARS.get(
717
+ (config.get("connectorKey", ""), config.get("objectName", ""))
718
+ )
719
+ if _param_grammars:
720
+ entry["operation"]["paramGrammars"] = _param_grammars
721
+ return entry
722
+
723
+
724
+ # ---------------------------------------------------------------------------
725
+ # `is connectors metadata` enrichment (connector-level, not action-level)
726
+ # ---------------------------------------------------------------------------
727
+ #
728
+ # Each connector record carries the display name and the four discovery
729
+ # capabilities from element metadata. Phase 1 deliberately applies the derived
730
+ # schema flag to every activity for the connector.
731
+
732
+ def is_connector_get(connector_key, cache_dir):
733
+ """Cached `uip is connectors metadata <key>`.
734
+
735
+ Returns the parsed first-element dict, or None on any failure.
736
+ """
737
+ # Do not reuse legacy `connectors get` cache files: that output omitted the
738
+ # discovery capabilities and would silently mark every connector static.
739
+ safe = _safe_filename(connector_key, "_connector_metadata")
740
+ cache_file = os.path.join(cache_dir, f"{safe}.json")
741
+ if os.path.exists(cache_file):
742
+ with open(cache_file) as f:
743
+ d = json.load(f)
744
+ data = d.get("Data") or []
745
+ return data[0] if isinstance(data, list) and data else None
746
+ result = subprocess.run(
747
+ ["uip", "is", "connectors", "metadata", connector_key,
748
+ "--output", "json"],
749
+ capture_output=True, text=True
750
+ )
751
+ try:
752
+ payload = json.loads(result.stdout)
753
+ except json.JSONDecodeError:
754
+ return None
755
+ if payload.get("Result") != "Success":
756
+ with open(cache_file, "w") as f:
757
+ json.dump({"Result": "Failure", "_cached_failure": True}, f)
758
+ return None
759
+ with open(cache_file, "w") as f:
760
+ json.dump(payload, f)
761
+ data = payload.get("Data") or []
762
+ return data[0] if isinstance(data, list) and data else None
763
+
764
+
765
+ def fetch_connector_records(entries, cache_dir, concurrency):
766
+ """Fetch one `is connectors metadata` per unique connector key in entries.
767
+
768
+ Returns a dict of connector_key → connector record (or None).
769
+ """
770
+ keys = sorted({e["connector"]["key"] for e in entries if e["connector"]["key"]})
771
+ return fetch_connector_records_for_keys(keys, cache_dir, concurrency)
772
+
773
+
774
+ def fetch_connector_records_for_keys(keys, cache_dir, concurrency):
775
+ """Fetch `is connectors metadata` for an iterable of connector keys."""
776
+ os.makedirs(cache_dir, exist_ok=True)
777
+ keys = sorted({key for key in keys if key})
778
+ print(f" Looking up {len(keys)} connector records...")
779
+ out = {}
780
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
781
+ futures = {pool.submit(is_connector_get, k, cache_dir): k for k in keys}
782
+ done = 0
783
+ for fut in as_completed(futures):
784
+ k = futures[fut]
785
+ done += 1
786
+ try:
787
+ out[k] = fut.result()
788
+ except Exception:
789
+ out[k] = None
790
+ if done % 50 == 0 or done == len(keys):
791
+ print(f" Fetched {done}/{len(keys)} connector records")
792
+ return out
793
+
794
+
795
+ def action_connector_key(node_type):
796
+ """Return the connector key for an ACTION node type, or None.
797
+
798
+ Action node types are `uipath.connector.<key>.<operation>`. The connector
799
+ event forms carry an extra segment
800
+ (`uipath.connector.{trigger,event}.<key>.<event>`) and are answered by
801
+ `event_connector_key` instead.
802
+ """
803
+ prefix = "uipath.connector."
804
+ if not node_type.startswith(prefix):
805
+ return None
806
+ remainder = node_type[len(prefix):]
807
+ if remainder.startswith(("trigger.", "event.")):
808
+ return None
809
+ connector_key, separator, _operation = remainder.partition(".")
810
+ return connector_key if separator and connector_key else None
811
+
812
+
813
+ def event_connector_key(node_type):
814
+ """Return the connector key for a WAIT-form event node type, or None.
815
+
816
+ The platform ships every connector event twice — the mid-flow wait
817
+ `uipath.connector.event.<key>.<event>` and the start trigger
818
+ `uipath.connector.trigger.<key>.<event>` — and only the WAIT form is
819
+ answered here, because only it can be generated. `registry get` REFUSES
820
+ the start form without a live `--connection-id` (a build would then be
821
+ gated on owning a connection for every trigger-supporting connector),
822
+ while the wait form answers offline and carries the same identity and
823
+ payload schema. The SDK derives the start form from its wait sibling when
824
+ it resolves one (`deriveTriggerForm`), so the single generated entry
825
+ serves both node types.
826
+ """
827
+ prefix = "uipath.connector.event."
828
+ if not node_type.startswith(prefix):
829
+ return None
830
+ connector_key, separator, _event = node_type[len(prefix):].partition(".")
831
+ return connector_key if separator and connector_key else None
832
+
833
+
834
+ def scoped_connector_key(node_type):
835
+ """The connector a node type belongs to, for SCOPE filtering.
836
+
837
+ A scope names CONNECTORS, so a scoped build has to be a subset of the full
838
+ one — same connectors, same node families. Keying scope on the action form
839
+ alone made it something else: every connector event silently vanished,
840
+ because `action_connector_key` answers None for them and None matches no
841
+ connector. Measured on the tier1 run of 2026-09-04: 507 action node types
842
+ kept and all 125 event node types dropped, so a scoped library disagreed
843
+ with the full library (448 events) about which events exist at all.
844
+ """
845
+ return action_connector_key(node_type) or event_connector_key(node_type)
846
+
847
+
848
+ FIRST_PARTY_PREFIX = "uipath-"
849
+ MOCK_MARKER = "mock"
850
+
851
+
852
+ def is_first_party_connector(key):
853
+ """Does this connector key belong in the PUBLISHED catalog?
854
+
855
+ The registry answers with everything the tenant can see, which on a build
856
+ tenant is not the same set an author should be offered:
857
+
858
+ * `custom-<org>-<name>` are connectors somebody built on that tenant. They
859
+ exist nowhere else, so shipping them advertises operations that resolve
860
+ for no other user — measured on the 2026-09 build: 9 keys, 168 entries,
861
+ including `custom-codereval-nulldisplayrepro` and
862
+ `custom-codereval-customconnectortest`, which are bug repros.
863
+ * `uipath-mock-*` are the fixtures the eval harness dispatches against.
864
+ Three keys, 470 entries — the single largest of them, `uipath-mock-jira`
865
+ at 282, is bigger than most real connectors, so an agent searching for
866
+ "jira" meets the mock before the product.
867
+
868
+ Together 12 of 161 keys and 638 of 8,035 entries (7.9%). An explicit
869
+ `--connectors <key>` still builds either kind: naming one is a statement
870
+ that you want it, and the eval harness does.
871
+ """
872
+ lowered = str(key or "").lower()
873
+ return lowered.startswith(FIRST_PARTY_PREFIX) and MOCK_MARKER not in lowered
874
+
875
+
876
+ def catalog_connector_key(node_type):
877
+ """The connector a node type belongs to, in ANY of its three forms.
878
+
879
+ `scoped_connector_key` answers the two forms a build can GENERATE — the
880
+ action and the WAIT event — and deliberately not the START trigger, which
881
+ `registry get` refuses without a live connection. Membership of a connector
882
+ is a different question from generability: `uipath.connector.trigger.<key>
883
+ .<event>` plainly belongs to `<key>`, and a catalog filter reading it as
884
+ "not a connector node" would keep a tenant-local connector's trigger while
885
+ dropping its own action and wait siblings — the same half-dropped catalog
886
+ `scoped_connector_key` was written to prevent, one form over.
887
+ """
888
+ key = scoped_connector_key(node_type)
889
+ if key:
890
+ return key
891
+ prefix = "uipath.connector.trigger."
892
+ if not str(node_type or "").startswith(prefix):
893
+ return None
894
+ candidate, _, event = node_type[len(prefix):].partition(".")
895
+ return candidate if candidate and event else None
896
+
897
+
898
+ def select_published_connectors(node_types):
899
+ """`(kept node types, dropped connector keys)` for a FULL build.
900
+
901
+ A node type with no connector key at all is not a connector node, so the
902
+ catalog rule has nothing to say about it and it is kept — dropping those
903
+ would be this filter deleting core nodes. Splitting this out of `main` is
904
+ what makes the selection testable without a registry.
905
+ """
906
+ dropped = sorted({
907
+ key for key in (catalog_connector_key(nt) for nt in node_types)
908
+ if key and not is_first_party_connector(key)
909
+ })
910
+ if not dropped:
911
+ return list(node_types), []
912
+ kept = [
913
+ nt for nt in node_types
914
+ if is_first_party_connector(catalog_connector_key(nt))
915
+ or not catalog_connector_key(nt)
916
+ ]
917
+ return kept, dropped
918
+
919
+
920
+ def connector_tier(record):
921
+ """Read the connector tier across CLI response casing variants."""
922
+ if not isinstance(record, dict):
923
+ return None
924
+ value = record.get("Tier")
925
+ if value is None:
926
+ value = record.get("tier")
927
+ return str(value).strip() if value is not None else None
928
+
929
+
930
+ def normalized_connector_tier(record):
931
+ """Normalize current and legacy connector tier spellings."""
932
+ value = connector_tier(record)
933
+ if value is None:
934
+ return None
935
+ normalized = re.sub(r"[^a-z0-9]", "", value.lower())
936
+ if normalized in {"1", "t1", "tier1"}:
937
+ return "tier1"
938
+ if normalized in {"2", "t2", "tier2"}:
939
+ return "tier2"
940
+ return None
941
+
942
+
943
+ def filter_node_types_by_tier(node_types, connector_records, tier):
944
+ """Keep the node types whose connector metadata declares `tier`.
945
+
946
+ Actions and connector events both, per `scoped_connector_key` — a tier is
947
+ a set of connectors, and dropping a connector's events while keeping its
948
+ actions ships a library that contradicts the full build.
949
+ """
950
+ if tier not in {"tier1", "tier2"}:
951
+ raise ValueError(f"unsupported connector tier: {tier}")
952
+ matching_keys = {
953
+ key for key, record in connector_records.items()
954
+ if normalized_connector_tier(record) == tier
955
+ }
956
+ return [
957
+ node_type for node_type in node_types
958
+ if scoped_connector_key(node_type) in matching_keys
959
+ ], matching_keys
960
+
961
+
962
+ def merge_connector_record(entry, record):
963
+ """Stamp connector discovery metadata and its Phase 1 derived flag."""
964
+ if not record:
965
+ return entry
966
+ name = record.get("Name")
967
+ if name:
968
+ entry["connector"]["name"] = name
969
+ tier = connector_tier(record)
970
+ if tier is not None:
971
+ entry["connector"]["tier"] = tier
972
+ discovery = {
973
+ "type": record.get("DiscoveryType") or "",
974
+ "hasObjectDiscovery": _as_bool(record.get("HasObjectDiscovery")),
975
+ "hasFieldDiscovery": _as_bool(record.get("HasFieldDiscovery")),
976
+ "hasCustomObjectDiscovery": _as_bool(
977
+ record.get("HasCustomObjectDiscovery")
978
+ ),
979
+ "hasCustomFieldDiscovery": _as_bool(
980
+ record.get("HasCustomFieldDiscovery")
981
+ ),
982
+ }
983
+ entry["connector"]["discovery"] = discovery
984
+ entry["runtime"]["requiresConnectionForSchema"] = any(
985
+ value for key, value in discovery.items() if key != "type"
986
+ )
987
+ return entry
988
+
989
+
990
+ def _as_bool(value):
991
+ """Accept the boolean JSON contract and older Yes/No cache rows."""
992
+ if isinstance(value, bool):
993
+ return value
994
+ if isinstance(value, str):
995
+ return value.strip().lower() in {"true", "yes", "1"}
996
+ return False
997
+
998
+
999
+ # ---------------------------------------------------------------------------
1000
+ # `is resources describe` enrichment
1001
+ # ---------------------------------------------------------------------------
1002
+ #
1003
+ # `flow registry get` (without --connection-id) returns a sparse view: many
1004
+ # connectors come back with empty inputDefinition/outputDefinition and no
1005
+ # connectorMethodInfo. `is resources describe <connector> <object>
1006
+ # --operation <op>` returns the same data plus path placeholders, parameter
1007
+ # split (path vs query), and full request/response field schemas — and
1008
+ # crucially does so without a connection ID for ALL schema-static connectors.
1009
+ #
1010
+ # Schema-DYNAMIC connectors (Salesforce SFDC and similar) return empty
1011
+ # requestFields/responseFields without a connection-id. We detect this and
1012
+ # tag the entry so downstream tooling knows to consult a per-flow sidecar.
1013
+
1014
+ def _safe_filename(*parts):
1015
+ """Sanitize parts of a filename so they survive on any FS."""
1016
+ s = "__".join(parts)
1017
+ return re.sub(r"[^a-zA-Z0-9_.@\-]+", "_", s)
1018
+
1019
+
1020
+ def _schema_cache_scope():
1021
+ """Keep static and per-connection schema responses from sharing a cache."""
1022
+ return _CONNECTION_ID or "_static"
1023
+
1024
+
1025
+ def _pick(d, *keys):
1026
+ """Return the first non-None value among d[k] for k in keys.
1027
+
1028
+ The `uip is resources describe` CLI switched key casing from camelCase
1029
+ to PascalCase at some point (e.g. `availableOperations` →
1030
+ `AvailableOperations`, `requestFields` → `RequestFields`). Use this in
1031
+ every IS-payload read so we keep working under either CLI version.
1032
+ """
1033
+ if not isinstance(d, dict):
1034
+ return None
1035
+ for k in keys:
1036
+ v = d.get(k)
1037
+ if v is not None:
1038
+ return v
1039
+ return None
1040
+
1041
+
1042
+ def _to_lower_camel(s):
1043
+ """Map a PascalCase key to camelCase (first character lowered)."""
1044
+ if not isinstance(s, str) or not s:
1045
+ return s
1046
+ if s[0].isupper():
1047
+ return s[0].lower() + s[1:]
1048
+ return s
1049
+
1050
+
1051
+ def _normalize_field_keys(value):
1052
+ """Recursively lower-camel-case every dict key in `value`.
1053
+
1054
+ The IS CLI started returning PascalCase keys for parameters, fields,
1055
+ and references; the existing library uses camelCase, so we normalize
1056
+ on read to keep the on-disk schema stable across CLI versions.
1057
+ """
1058
+ if isinstance(value, dict):
1059
+ return {
1060
+ _to_lower_camel(k): _normalize_field_keys(v)
1061
+ for k, v in value.items()
1062
+ }
1063
+ if isinstance(value, list):
1064
+ return [_normalize_field_keys(v) for v in value]
1065
+ return value
1066
+
1067
+
1068
+ def is_describe_operation(connector_key, object_name, operation, cache_dir):
1069
+ """Cached `uip is resources describe <connector> <object> --operation <op>`.
1070
+
1071
+ Returns the parsed Data dict on success, or None on any failure (the
1072
+ connector/object/operation combo doesn't exist, network error, etc.).
1073
+ """
1074
+ parts = [_schema_cache_scope(), connector_key, object_name, operation]
1075
+ field_scope = _field_scope()
1076
+ if field_scope:
1077
+ parts.append(field_scope)
1078
+ safe = _safe_filename(*parts)
1079
+ cache_file = os.path.join(cache_dir, f"{safe}.json")
1080
+ if os.path.exists(cache_file):
1081
+ with open(cache_file) as f:
1082
+ cached = json.load(f)
1083
+ if cached.get("_cached_failure"):
1084
+ _note_failed_field_describe(
1085
+ connector_key, object_name, operation, cached.get("Message")
1086
+ )
1087
+ return cached.get("Data")
1088
+
1089
+ result = subprocess.run(
1090
+ ["uip", "is", "resources", "describe", connector_key, object_name,
1091
+ "--operation", operation, "--output", "json"]
1092
+ + _conn_args() + _field_args(),
1093
+ capture_output=True, text=True
1094
+ )
1095
+ try:
1096
+ payload = json.loads(result.stdout)
1097
+ except json.JSONDecodeError:
1098
+ return None
1099
+ if payload.get("Result") != "Success":
1100
+ _note_failed_field_describe(
1101
+ connector_key, object_name, operation, payload.get("Message")
1102
+ )
1103
+ # Cache the failure too, with a sentinel, so retries are skipped. Keep
1104
+ # the message: it is the actionable half of a parent-field rejection.
1105
+ with open(cache_file, "w") as f:
1106
+ json.dump({
1107
+ "Result": "Failure",
1108
+ "_cached_failure": True,
1109
+ "Message": payload.get("Message"),
1110
+ }, f)
1111
+ return None
1112
+ with open(cache_file, "w") as f:
1113
+ json.dump(payload, f)
1114
+ return payload.get("Data")
1115
+
1116
+
1117
+ def is_describe_object(connector_key, object_name, cache_dir):
1118
+ """Cached `uip is resources describe <connector> <object>` (no --operation).
1119
+
1120
+ Returns the parsed Data dict on success (with `AvailableOperations`),
1121
+ or None on failure.
1122
+ """
1123
+ safe = _safe_filename(
1124
+ _schema_cache_scope(), connector_key, object_name, "_operations"
1125
+ )
1126
+ cache_file = os.path.join(cache_dir, f"{safe}.json")
1127
+ if os.path.exists(cache_file):
1128
+ with open(cache_file) as f:
1129
+ return json.load(f).get("Data")
1130
+ result = subprocess.run(
1131
+ ["uip", "is", "resources", "describe", connector_key, object_name,
1132
+ "--output", "json"] + _conn_args(),
1133
+ capture_output=True, text=True
1134
+ )
1135
+ try:
1136
+ payload = json.loads(result.stdout)
1137
+ except json.JSONDecodeError:
1138
+ return None
1139
+ if payload.get("Result") != "Success":
1140
+ with open(cache_file, "w") as f:
1141
+ json.dump({"Result": "Failure", "_cached_failure": True}, f)
1142
+ return None
1143
+ with open(cache_file, "w") as f:
1144
+ json.dump(payload, f)
1145
+ return payload.get("Data")
1146
+
1147
+
1148
+ def is_resources_list(connector_key, cache_dir, operation=None, refresh=False):
1149
+ """Cached `uip is resources list <connector>` — returns the object catalog.
1150
+
1151
+ Each entry has `Name`, `DisplayName`, `Path`, `Type`, `SubType`.
1152
+ Returns the list on success (possibly empty), or None on failure.
1153
+
1154
+ `refresh` bypasses BOTH caches — this module's file cache and the CLI's own.
1155
+ `uip is resources list` answers from a local cache that never invalidates
1156
+ itself, so an object the tenant gained after that cache was written reads as
1157
+ nonexistent. Measured on uipath-salesforce-sfdc: 18 objects from the stale
1158
+ cache, 864 with `--refresh`, and `InvoiceShare` only in the second. Refreshing
1159
+ on every call would cost a round trip per connector, so callers ask for it
1160
+ when a lookup misses (see `_list_generic_objects`).
1161
+ """
1162
+ safe = _safe_filename(
1163
+ _schema_cache_scope(), connector_key, operation or "_all_operations",
1164
+ "_resources_list"
1165
+ )
1166
+ cache_file = os.path.join(cache_dir, f"{safe}.json")
1167
+ if not refresh and os.path.exists(cache_file):
1168
+ with open(cache_file) as f:
1169
+ d = json.load(f)
1170
+ return d.get("Data") if isinstance(d.get("Data"), list) else None
1171
+ command = ["uip", "is", "resources", "list", connector_key]
1172
+ if operation:
1173
+ command.extend(["--operation", operation])
1174
+ if refresh:
1175
+ command.append("--refresh")
1176
+ command.extend(["--output", "json"])
1177
+ result = subprocess.run(
1178
+ command + _conn_args(),
1179
+ capture_output=True, text=True
1180
+ )
1181
+ try:
1182
+ payload = json.loads(result.stdout)
1183
+ except json.JSONDecodeError:
1184
+ return None
1185
+ if payload.get("Result") != "Success":
1186
+ with open(cache_file, "w") as f:
1187
+ json.dump({"Result": "Failure", "_cached_failure": True}, f)
1188
+ return None
1189
+ with open(cache_file, "w") as f:
1190
+ json.dump(payload, f)
1191
+ return payload.get("Data") if isinstance(payload.get("Data"), list) else None
1192
+
1193
+
1194
+ # Map from registry httpMethod (e.g. "GETBYID") to a guess at the
1195
+ # `is resources` operation name. The `is resources` API uses verb names like
1196
+ # "Retrieve" / "List" / "Create"; the registry uses HTTP-method-shaped strings.
1197
+ HTTP_METHOD_TO_OPERATION = {
1198
+ "GET": "List",
1199
+ "GETBYID": "Retrieve",
1200
+ "POST": "Create",
1201
+ "PATCH": "Update",
1202
+ "PUT": "Replace",
1203
+ "DELETE": "Delete",
1204
+ }
1205
+
1206
+
1207
+ def discover_operation_name(entry, is_cache_dir):
1208
+ """Find the `is resources` operation name for an entry whose
1209
+ canonical `operation.name` is empty.
1210
+
1211
+ Strategy: list available operations on the object, then match by the
1212
+ httpMethod we already extracted from the registry.
1213
+ """
1214
+ connector_key = entry["connector"]["key"]
1215
+ object_name = entry["operation"]["objectName"]
1216
+ http_method = entry["operation"]["httpMethod"]
1217
+ if not connector_key or not object_name:
1218
+ return None
1219
+ obj_data = is_describe_object(connector_key, object_name, is_cache_dir)
1220
+ if not obj_data:
1221
+ return None
1222
+ for op in _pick(obj_data, "AvailableOperations", "availableOperations") or []:
1223
+ if _pick(op, "Method", "method") == http_method:
1224
+ return _pick(op, "Name", "name")
1225
+ return None
1226
+
1227
+
1228
+ def enrich_entry(entry, is_cache_dir):
1229
+ """Augment a canonical entry with `is resources describe` data.
1230
+
1231
+ Returns the (mutated) entry. On any failure (object not found, no
1232
+ operation match, etc.) returns the entry unchanged so the pipeline
1233
+ degrades gracefully.
1234
+ """
1235
+ connector_key = entry["connector"]["key"]
1236
+ object_name = entry["operation"]["objectName"]
1237
+ op_name = entry["operation"]["name"]
1238
+
1239
+ # Fallback: no operation name from the registry — discover via httpMethod.
1240
+ if not op_name:
1241
+ op_name = discover_operation_name(entry, is_cache_dir) or \
1242
+ HTTP_METHOD_TO_OPERATION.get(entry["operation"]["httpMethod"])
1243
+ if not op_name:
1244
+ return entry # nothing to do
1245
+
1246
+ data = is_describe_operation(connector_key, object_name, op_name, is_cache_dir)
1247
+ # The registry's verb and the IS CLI's can DISAGREE for the same operation,
1248
+ # and the disagreement used to end enrichment silently — the entry kept its
1249
+ # registry `path`, gained no `pathTemplate`, and carried NO `parameters` at
1250
+ # all, which is an operation nothing can dispatch. Measured on Data Service
1251
+ # `QueryEntityRecordsCurated`: the registry says
1252
+ # `connectorMethodInfo.operation: "List"`, and `describe … --operation List`
1253
+ # answers *"Operation 'List' not found. Available: Create"*. So when the
1254
+ # registry's name does not resolve, ask the OBJECT which operations it
1255
+ # actually has and retry with the one whose HTTP method matches
1256
+ # (`discover_operation_name` — already written for the empty-name case, and
1257
+ # the same question one condition over).
1258
+ #
1259
+ # `operation.name` deliberately keeps the REGISTRY's verb even when the
1260
+ # describe answered under another: it maps to a CRUD verb (List →
1261
+ # `list`) and gen-tests synthesises the offline fixture from it, so
1262
+ # rewriting it here would change how a correct flow replays. What the
1263
+ # discovered name is for is reaching the schema.
1264
+ if not data:
1265
+ alt = discover_operation_name(entry, is_cache_dir)
1266
+ if alt and alt != op_name:
1267
+ data = is_describe_operation(connector_key, object_name, alt, is_cache_dir)
1268
+ if not data:
1269
+ return entry # connector might be a stub or recently removed
1270
+
1271
+ op_data = _pick(data, "Operation", "operation") or {}
1272
+
1273
+ # If we'd discovered the name via fallback, persist it now.
1274
+ op_resolved_name = _pick(op_data, "Name", "name")
1275
+ if not entry["operation"]["name"] and op_resolved_name:
1276
+ entry["operation"]["name"] = op_resolved_name
1277
+
1278
+ # Path with placeholders (supersedes the simple `path` we extracted).
1279
+ op_path = _pick(op_data, "Path", "path")
1280
+ if op_path:
1281
+ entry["operation"]["pathTemplate"] = op_path
1282
+ # Object/operation display name from `is resources` data.
1283
+ if not entry["operation"].get("objectDisplayName"):
1284
+ display_name = _pick(data, "DisplayName", "displayName")
1285
+ if display_name:
1286
+ entry["operation"]["objectDisplayName"] = display_name
1287
+ op_curated = _pick(op_data, "Curated", "curated")
1288
+ if op_curated and not entry["display"].get("operationLabel"):
1289
+ entry["display"]["operationLabel"] = op_curated
1290
+
1291
+ # Parameters (path / query) — keep the raw shape (camelCase keys);
1292
+ # downstream tooling filters by `type` (path | query).
1293
+ params = _pick(data, "Parameters", "parameters") or []
1294
+ if params:
1295
+ entry["operation"]["parameters"] = _normalize_field_keys(params)
1296
+
1297
+ # Do not infer schema discovery from empty field lists: delete and file
1298
+ # operations legitimately expose neither. Connector metadata owns that flag.
1299
+ request_fields = _pick(data, "RequestFields", "requestFields") or []
1300
+ response_fields = _pick(data, "ResponseFields", "responseFields") or []
1301
+
1302
+ if request_fields:
1303
+ # Replace/augment the registry-derived inputSchema.
1304
+ entry["inputSchema"] = {"fields": _normalize_field_keys(request_fields)}
1305
+ if response_fields:
1306
+ entry["outputSchema"] = {"fields": _normalize_field_keys(response_fields)}
1307
+
1308
+ return entry
1309
+
1310
+
1311
+ def enrich_all(entries, is_cache_dir, concurrency):
1312
+ """Run enrich_entry on every canonical entry in parallel."""
1313
+ os.makedirs(is_cache_dir, exist_ok=True)
1314
+ enriched = [None] * len(entries)
1315
+ pathTemplate_added = 0
1316
+ schema_added = 0
1317
+
1318
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
1319
+ futures = {pool.submit(enrich_entry, e, is_cache_dir): i
1320
+ for i, e in enumerate(entries)}
1321
+ done = 0
1322
+ for fut in as_completed(futures):
1323
+ i = futures[fut]
1324
+ done += 1
1325
+ try:
1326
+ enriched[i] = fut.result()
1327
+ except Exception as exc:
1328
+ # Non-fatal — keep the un-enriched entry.
1329
+ enriched[i] = entries[i]
1330
+ print(f" WARN: enrichment failed for {entries[i]['nodeType']}: {exc}",
1331
+ file=sys.stderr)
1332
+ if done % 100 == 0 or done == len(entries):
1333
+ print(f" Enriched {done}/{len(entries)}")
1334
+
1335
+ for e in enriched:
1336
+ if e.get("operation", {}).get("pathTemplate"):
1337
+ pathTemplate_added += 1
1338
+ if e.get("inputSchema", {}).get("fields") or e.get("outputSchema", {}).get("fields"):
1339
+ schema_added += 1
1340
+
1341
+ print(f" pathTemplate added on {pathTemplate_added}/{len(entries)}")
1342
+ print(f" schema added/augmented on {schema_added}/{len(entries)}")
1343
+ return enriched
1344
+
1345
+
1346
+ # ---------------------------------------------------------------------------
1347
+ # Generic-action expansion
1348
+ # ---------------------------------------------------------------------------
1349
+ #
1350
+ # A Generic registry action (e.g. `uipath.connector.<key>.insert-record`) is
1351
+ # an untyped runtime dispatcher: the object (Contact, Issue, …) is picked at
1352
+ # design time, not baked into the action. We materialize one library entry
1353
+ # per `(connector, object, operation)` the IS resources catalog advertises,
1354
+ # using the matching Generic action (by HTTP method) as a template.
1355
+ #
1356
+ # Layout (per the design decision):
1357
+ # library/<connector>/generic/<object>/<op>@<ver>.json
1358
+ #
1359
+ # The expanded entries keep the *source* Generic nodeType so an emitter can produce
1360
+ # a valid Flow node by re-stamping `operation.objectName` onto the same
1361
+ # nodeType the editor uses.
1362
+
1363
+ # Registry CRUD verbs are the recognised Generic templates. HTTP escape
1364
+ # hatches (`*-http-request`, objectName usually `httpRequest` but
1365
+ # sometimes null) are kept as-is, not expanded — there's no object catalog
1366
+ # to iterate.
1367
+ _HTTP_PASSTHROUGH_OBJECT_NAMES = {"httpRequest", "HttpRequest", "http_request"}
1368
+
1369
+
1370
+ def _is_http_passthrough(entry):
1371
+ if entry["operation"].get("objectName") in _HTTP_PASSTHROUGH_OBJECT_NAMES:
1372
+ return True
1373
+ # Some connectors leave objectName null on the http-request action;
1374
+ # fall back to recognising the nodeType suffix.
1375
+ action_id = _action_id_from_node_type(entry["nodeType"])
1376
+ return action_id.endswith("http-request")
1377
+
1378
+
1379
+ def _generic_templates_by_op(generic_entries):
1380
+ """Bucket Generic CRUD entries by IS operation name per connector.
1381
+
1382
+ Generic CRUD actions carry an `operation` verb in their configuration
1383
+ (e.g. `Create`/`Delete`/`Retrieve`/`List`/`Update`/`Replace`) but no
1384
+ `httpMethod`. The IS resources catalog returns the same verb names
1385
+ under `AvailableOperations[].Name`, so bucketing by op name lets us
1386
+ match each (object, op) advertised by IS to the right template.
1387
+
1388
+ Returns {connector_key: {op_name: entry}}.
1389
+ """
1390
+ out = {}
1391
+ for e in generic_entries:
1392
+ if _is_http_passthrough(e):
1393
+ continue
1394
+ op_name = (e["operation"].get("name") or "").strip()
1395
+ if not op_name:
1396
+ continue
1397
+ key = e["connector"]["key"]
1398
+ if not key:
1399
+ continue
1400
+ out.setdefault(key, {})[op_name] = e
1401
+ return out
1402
+
1403
+
1404
+ def _make_expanded_entry(template, obj, op, op_data):
1405
+ """Build one expanded entry from a Generic template + (object, op) pair.
1406
+
1407
+ `obj` is one element of `uip is resources list` (PascalCase keys).
1408
+ `op` is one element of `Data.AvailableOperations` from
1409
+ `uip is resources describe <key> <obj>` (PascalCase keys).
1410
+ `op_data` is the `Data` payload of
1411
+ `uip is resources describe <key> <obj> --operation <op_name>`
1412
+ (may be None if the call failed — the entry is still written with
1413
+ an empty schema). The connector-derived discovery flag is copied
1414
+ from the template and is never inferred from field counts here.
1415
+ """
1416
+ entry = json.loads(json.dumps(template)) # deep copy
1417
+
1418
+ object_name = obj.get("Name") or ""
1419
+ object_display = obj.get("DisplayName") or object_name
1420
+ op_name = _pick(op, "Name", "name") or ""
1421
+ op_method = _pick(op, "Method", "method") or template["operation"].get("httpMethod", "")
1422
+ op_path = _pick(op, "Path", "path") or ""
1423
+ op_description = _pick(op, "Description", "description") or ""
1424
+
1425
+ entry["operation"]["objectName"] = object_name
1426
+ entry["operation"]["objectDisplayName"] = object_display
1427
+ entry["operation"]["name"] = op_name
1428
+ entry["operation"]["httpMethod"] = op_method
1429
+ entry["operation"]["path"] = op_path
1430
+ entry["operation"]["subType"] = obj.get("SubType") or template["operation"].get("subType", "")
1431
+ entry["operation"]["activityType"] = "GenericExpanded"
1432
+ entry["operation"]["sourceNodeType"] = template["nodeType"]
1433
+
1434
+ entry["display"]["label"] = f"{op_name} {object_display}".strip()
1435
+ if op_description:
1436
+ entry["display"]["description"] = op_description
1437
+
1438
+ # Pull schema + path template from the per-operation describe payload.
1439
+ if op_data:
1440
+ op_inner = _pick(op_data, "Operation", "operation") or {}
1441
+ op_inner_path = _pick(op_inner, "Path", "path")
1442
+ if op_inner_path:
1443
+ entry["operation"]["pathTemplate"] = op_inner_path
1444
+ params = _pick(op_data, "Parameters", "parameters") or []
1445
+ if params:
1446
+ entry["operation"]["parameters"] = _normalize_field_keys(params)
1447
+ request_fields = _pick(op_data, "RequestFields", "requestFields") or []
1448
+ response_fields = _pick(op_data, "ResponseFields", "responseFields") or []
1449
+ entry["inputSchema"] = {"fields": _normalize_field_keys(request_fields)}
1450
+ entry["outputSchema"] = {"fields": _normalize_field_keys(response_fields)}
1451
+ else:
1452
+ entry["inputSchema"] = {"fields": []}
1453
+ entry["outputSchema"] = {"fields": []}
1454
+
1455
+ return entry
1456
+
1457
+
1458
+ def expand_generic_for_connector(
1459
+ connector_key, templates_by_op, is_cache_dir, object_names=None
1460
+ ):
1461
+ """Materialize per-(object, op) entries for one connector.
1462
+
1463
+ Returns a list of expanded entries (possibly empty if the connector has
1464
+ no object catalog or the IS calls fail).
1465
+ """
1466
+ # A prepared overlay usually carries one Generic template. Let the CLI
1467
+ # filter the connected catalog by that verb before we describe objects.
1468
+ objects = _list_generic_objects(
1469
+ connector_key, templates_by_op, is_cache_dir, object_names=object_names
1470
+ )
1471
+ if not objects:
1472
+ return []
1473
+
1474
+ # Dedupe by (object_name, op_name). Some IS resource catalogs list
1475
+ # the same object twice (e.g. uipath-servicenow-servicenow exposes
1476
+ # `curated_incident_task` twice — a 1-of-2,283 quirk that still
1477
+ # propagates into the index as duplicate library entries with
1478
+ # identical paths). Within one connector, the (object, op) pair
1479
+ # should be unique; skip subsequent duplicates.
1480
+ seen_pairs: set[tuple[str, str]] = set()
1481
+ out = []
1482
+ for obj in objects:
1483
+ object_name = obj.get("Name")
1484
+ if not object_name:
1485
+ continue
1486
+ if object_names is not None and object_name not in object_names:
1487
+ continue
1488
+ obj_data = is_describe_object(connector_key, object_name, is_cache_dir)
1489
+ if not obj_data:
1490
+ continue
1491
+ available = _pick(obj_data, "AvailableOperations", "availableOperations") or []
1492
+ for op in available:
1493
+ op_name = _pick(op, "Name", "name")
1494
+ if not op_name:
1495
+ continue
1496
+ pair = (object_name, op_name)
1497
+ if pair in seen_pairs:
1498
+ continue
1499
+ seen_pairs.add(pair)
1500
+ template = templates_by_op.get(op_name)
1501
+ if not template:
1502
+ # No matching Generic action — e.g. the object exposes an
1503
+ # operation whose verb the connector doesn't ship as a
1504
+ # registry node (rare). Skip.
1505
+ continue
1506
+ op_data = is_describe_operation(
1507
+ connector_key, object_name, op_name, is_cache_dir
1508
+ )
1509
+ out.append(_make_expanded_entry(template, obj, op, op_data))
1510
+ return out
1511
+
1512
+
1513
+ def _unique_by_name(objects):
1514
+ """First occurrence of each `Name`, in order."""
1515
+ unique = {}
1516
+ for obj in objects or []:
1517
+ object_name = obj.get("Name")
1518
+ if object_name and object_name not in unique:
1519
+ unique[object_name] = obj
1520
+ return unique
1521
+
1522
+
1523
+ def _list_generic_objects(
1524
+ connector_key, templates_by_op, is_cache_dir, object_names=None
1525
+ ):
1526
+ """List and deduplicate the objects available to one connector.
1527
+
1528
+ When the caller named specific objects and the catalog does not contain them,
1529
+ retry once with a refresh before believing it. A stale `uip is resources
1530
+ list` cache reports a real object as missing, and the error that follows —
1531
+ "object X was not found or does not support action Y" — sends the reader
1532
+ looking for a connector problem that is not there.
1533
+ """
1534
+ operation_filter = (
1535
+ next(iter(templates_by_op)) if len(templates_by_op) == 1 else None
1536
+ )
1537
+ objects = is_resources_list(
1538
+ connector_key, is_cache_dir, operation=operation_filter
1539
+ )
1540
+ unique = _unique_by_name(objects)
1541
+ if object_names and not set(object_names).issubset(unique):
1542
+ refreshed = _unique_by_name(is_resources_list(
1543
+ connector_key, is_cache_dir,
1544
+ operation=operation_filter, refresh=True,
1545
+ ))
1546
+ # Keep the cached answer when the refresh failed outright, so a network
1547
+ # blip does not turn a working lookup into "not found".
1548
+ if refreshed:
1549
+ unique = refreshed
1550
+ return list(unique.values())
1551
+
1552
+
1553
+ def _order_by_dependency(parents, fields):
1554
+ """Cascade order for parent fields: a parent another one needs comes first.
1555
+
1556
+ The action's `rules` are not in cascade order — Jira lists
1557
+ `fields.issuetype.id` before `fields.project.key` — but the order is
1558
+ recoverable, because the dependent field's own lookup path carries the
1559
+ other's name (`/project/{fields.project.key}/issuetypes`). `dependsOn` is
1560
+ honoured too where a connector sets it; on the connections measured it was
1561
+ absent even when the path made the dependency plain, so neither source is
1562
+ sufficient alone.
1563
+ """
1564
+ depends = {}
1565
+ for field in fields:
1566
+ name = field.get("name")
1567
+ if name not in parents:
1568
+ continue
1569
+ reference = field.get("reference") or {}
1570
+ path = str(reference.get("path") or "")
1571
+ explicit = reference.get("dependsOn") or []
1572
+ depends[name] = {
1573
+ other for other in parents
1574
+ if other != name
1575
+ and ("{" + other + "}" in path or other in explicit)
1576
+ }
1577
+ ordered, remaining = [], list(parents)
1578
+ while remaining:
1579
+ free = [p for p in remaining if not depends.get(p, set()) & set(remaining)]
1580
+ if not free:
1581
+ # A cycle should not happen; emit the rest rather than loop.
1582
+ ordered.extend(remaining)
1583
+ break
1584
+ ordered.extend(free)
1585
+ remaining = [p for p in remaining if p not in free]
1586
+ return ordered
1587
+
1588
+
1589
+ def schema_action(connector_method_info, input_fields):
1590
+ """The design-time action that resolves an operation's REAL field set.
1591
+
1592
+ Some operations do not have one field list. Jira `create-issue` describes to
1593
+ 2 fields with nothing supplied — just the parents that select the schema —
1594
+ and to 32 once a project and an issue type are given; a different issue type
1595
+ in the same project gives a different set again. Salesforce
1596
+ `curated_soqlQuery` is the same mechanism driven by the query text.
1597
+
1598
+ Studio Web runs these automatically as the parent fields change, and
1599
+ `uip is resources describe -f name=value` ports the same call. Neither is
1600
+ discoverable from the catalog today, because the library drops
1601
+ `connectorMethodInfo.design` — so an author reads 2 fields and concludes the
1602
+ operation takes two inputs.
1603
+
1604
+ Recorded here as what a caller has to DO, not as the raw action:
1605
+
1606
+ - `parents` the fields whose values select the schema, in cascade order
1607
+ - `trigger` a rule field carrying no `refFieldValues` is a button, not
1608
+ data: Salesforce's `generateSchema` has to be invoked
1609
+ explicitly, whereas Jira fires on the parents alone
1610
+ - `replaces` which half the result replaces. Jira remaps `input`, so it
1611
+ yields the REQUEST fields; a SOQL query remaps `output`, so it
1612
+ yields the RESPONSE shape. Saying "it gives you the inputs"
1613
+ would be wrong for half of them.
1614
+ """
1615
+ design = (connector_method_info or {}).get("design") or {}
1616
+ candidates = []
1617
+ for action in design.get("actions") or []:
1618
+ if action.get("actionType") != "api":
1619
+ continue
1620
+ parents, trigger = [], None
1621
+ for rule in action.get("rules") or []:
1622
+ name = rule.get("refFieldName")
1623
+ if not name:
1624
+ continue
1625
+ if rule.get("refFieldValues"):
1626
+ parents.append(name)
1627
+ elif trigger is None:
1628
+ trigger = name
1629
+ if not parents:
1630
+ continue
1631
+ candidates.append((action, parents, trigger))
1632
+ if not candidates:
1633
+ return None
1634
+
1635
+ # A node can declare more than one. SAP OData `list-all-entity-records`
1636
+ # offers GenerateSchemaWithExpand(entity, $expand) alongside
1637
+ # GenerateSchema(entity), and recording the first one read demands
1638
+ # `$expand` — an optional OData feature — before the schema resolves at
1639
+ # all. The smallest parent set is the one every caller can satisfy. Ties
1640
+ # keep declaration order.
1641
+ action, parents, trigger = min(candidates, key=lambda c: len(c[1]))
1642
+
1643
+ remap = (action.get("onSuccess") or {}).get("remapConfiguration") or {}
1644
+ record = {
1645
+ "name": action.get("name") or "",
1646
+ "parents": _order_by_dependency(parents, input_fields),
1647
+ }
1648
+ if trigger:
1649
+ record["trigger"] = trigger
1650
+ # Which half the result replaces. Jira create-issue remaps `input`, so it
1651
+ # yields REQUEST fields; a SOQL query remaps `output`, so it yields the
1652
+ # RESPONSE shape. Several — Drive `apply-file-labels`, SAP BAPI, SAP OData,
1653
+ # every Data Service node — remap BOTH, and calling that "input" hides
1654
+ # that the response shape changes too.
1655
+ replaces_input = remap.get("input") == "replace"
1656
+ replaces_output = remap.get("output") == "replace"
1657
+ if replaces_input and replaces_output:
1658
+ record["replaces"] = "both"
1659
+ elif replaces_input:
1660
+ record["replaces"] = "input"
1661
+ elif replaces_output:
1662
+ record["replaces"] = "output"
1663
+ return record
1664
+
1665
+
1666
+ def objects_come_from_the_connection(entry):
1667
+ """True when this connector's object set is only knowable per connection.
1668
+
1669
+ `hasObjectDiscovery` / `hasCustomObjectDiscovery` say the objects live on
1670
+ the tenant. Expanding those offline bakes whichever objects the generation
1671
+ tenant happened to have — a list that is incomplete by definition (it can
1672
+ never contain a caller's custom objects) and is not necessarily the
1673
+ caller's at all. Worse, the expansions advertise those objects as available
1674
+ nodes while carrying no schema, and collide with the curated nodes that do:
1675
+ Salesforce ships two entries labelled "Create Account", one with 13 fields
1676
+ and one with none.
1677
+
1678
+ So for these connectors we ship one entry per verb instead, marked for
1679
+ discovery, and let the author list the objects against their own
1680
+ connection. Field discovery alone does NOT qualify: the object set is still
1681
+ knowable offline, so expanding is still worth doing even though each
1682
+ object's fields have to be described live.
1683
+ """
1684
+ discovery = (entry.get("connector") or {}).get("discovery") or {}
1685
+ return bool(
1686
+ discovery.get("hasObjectDiscovery")
1687
+ or discovery.get("hasCustomObjectDiscovery")
1688
+ )
1689
+
1690
+
1691
+ def as_discovery_entry(entry):
1692
+ """Recast a Generic CRUD template as the discovery entry we ship for it.
1693
+
1694
+ The label matters as much as the flag: the expanded entries were labelled
1695
+ per object ("Update Account"), so nothing in the catalog ever read as
1696
+ "Update Record" and a caller asking for that found nothing.
1697
+ """
1698
+ entry["operation"]["objectFromDiscovery"] = True
1699
+ entry["operation"]["objectName"] = ""
1700
+ name = entry["operation"].get("name") or ""
1701
+ if name:
1702
+ entry["display"]["label"] = (
1703
+ "List Records" if name == "List" else f"{name} Record"
1704
+ )
1705
+ return entry
1706
+
1707
+
1708
+ def disambiguate_discovery_labels(entries):
1709
+ """Keep two discovery entries on one connector from sharing a label.
1710
+
1711
+ `as_discovery_entry` names by verb, so a connector carrying two List-ish
1712
+ Generic nodes ends up with two entries both labelled "List Records":
1713
+ Salesforce ships `list-records` AND `search-records`. The label is what an
1714
+ agent searches on, so identical labels leave the two indistinguishable.
1715
+ Rename only the colliding ones, from their own node type — a connector with
1716
+ a single List node keeps the canonical label.
1717
+ """
1718
+ groups = {}
1719
+ for entry in entries:
1720
+ if not (entry.get("operation") or {}).get("objectFromDiscovery"):
1721
+ continue
1722
+ key = (
1723
+ (entry.get("connector") or {}).get("key"),
1724
+ (entry.get("display") or {}).get("label"),
1725
+ )
1726
+ groups.setdefault(key, []).append(entry)
1727
+ for items in groups.values():
1728
+ if len(items) < 2:
1729
+ continue
1730
+ for entry in items:
1731
+ slug = (entry.get("nodeType") or "").rsplit(".", 1)[-1]
1732
+ if slug:
1733
+ entry["display"]["label"] = (
1734
+ slug.replace("-", " ").replace("_", " ").title()
1735
+ )
1736
+ return entries
1737
+
1738
+
1739
+ def triggers_objects_cached(connector_key, operation, cache_dir):
1740
+ """Cached `uip is triggers objects <key> <OPERATION>` — the EVENT catalog.
1741
+
1742
+ A different catalog from `is resources list`, not a view of it: outlook
1743
+ lists 42 objects for actions and exactly 2 that support the CREATED
1744
+ event. Called WITHOUT a connection on purpose — this pass only runs for
1745
+ connectors whose objects do not come from the connection, and for those
1746
+ the answer is identical either way, so generation stays credential-free.
1747
+ """
1748
+ safe = _safe_filename(_schema_cache_scope(), connector_key, operation,
1749
+ "_trigger_objects")
1750
+ cache_file = os.path.join(cache_dir, f"{safe}.json")
1751
+ if os.path.exists(cache_file):
1752
+ with open(cache_file) as f:
1753
+ return json.load(f).get("objects") or []
1754
+ try:
1755
+ rows = _events.triggers_objects(connector_key, operation)
1756
+ except _events.EventDiscoveryError as error:
1757
+ print(f" WARN: trigger objects failed for {connector_key} "
1758
+ f"{operation}: {error}", file=sys.stderr)
1759
+ rows = []
1760
+ with open(cache_file, "w") as f:
1761
+ json.dump({"objects": rows}, f)
1762
+ return rows
1763
+
1764
+
1765
+ def triggers_describe_cached(connector_key, operation, object_name, cache_dir):
1766
+ """Cached `uip is triggers describe <key> <OPERATION> <object>`.
1767
+
1768
+ The authority for an event's subscription vocabulary; the registry node's
1769
+ copy is a lossy mirror of it.
1770
+ """
1771
+ safe = _safe_filename(_schema_cache_scope(), connector_key, operation,
1772
+ object_name, "_trigger_describe")
1773
+ cache_file = os.path.join(cache_dir, f"{safe}.json")
1774
+ if os.path.exists(cache_file):
1775
+ with open(cache_file) as f:
1776
+ return json.load(f).get("describe") or {}
1777
+ try:
1778
+ data = _events.triggers_describe(connector_key, operation, object_name)
1779
+ except _events.EventDiscoveryError as error:
1780
+ print(f" WARN: trigger describe failed for {connector_key} "
1781
+ f"{operation} {object_name}: {error}", file=sys.stderr)
1782
+ data = {}
1783
+ with open(cache_file, "w") as f:
1784
+ json.dump({"describe": data}, f)
1785
+ return data
1786
+
1787
+
1788
+ def _apply_event_schema(entry, describe, obj):
1789
+ """Put an event's OWN schema on the entry, replacing the node manifest's.
1790
+
1791
+ A connector event's `inputDefinition` in the registry manifest is not the
1792
+ subscription's inputs — for outlook `email-received` it is the Message
1793
+ body (`subject`, `body.content`, `toRecipients[*]…`), i.e. the fields you
1794
+ would send to CREATE a message, which an event never sends anywhere. The
1795
+ subscription's actual inputs are its EVENT PARAMETERS, and the platform
1796
+ selects those per field from `fields[*].<field>.events.<OPERATION>`:
1797
+ verified on outlook Message, where describe answers `[parentFolderId]`
1798
+ for EMAIL_RECEIVED (its `events` map names that operation, required) and
1799
+ `[]` for CREATED, while FilterFields stays 15 for both.
1800
+
1801
+ So the entry's `inputSchema` becomes the union rule's answer — describe's
1802
+ `EventParameters` plus the object row's own `Parameters` — and the leftover
1803
+ manifest fields are dropped rather than advertised as inputs.
1804
+ """
1805
+ fields = _events.union_event_parameters(
1806
+ (describe or {}).get("EventParameters"), (obj or {}).get("Parameters"))
1807
+ entry["inputSchema"] = {"fields": fields}
1808
+ output_fields = _events.camel_fields((describe or {}).get("OutputFields"))
1809
+ if output_fields:
1810
+ entry["outputSchema"] = {"fields": output_fields}
1811
+ entry["eventSchema"] = {
1812
+ "eventParameters": fields,
1813
+ "filterFields": _events.camel_fields((describe or {}).get("FilterFields")),
1814
+ }
1815
+ for src, dst in (("EventMode", "eventMode"),
1816
+ ("ByoaConnection", "byoaConnection"),
1817
+ ("IsWebhookUrlVisible", "isWebhookUrlVisible")):
1818
+ if (describe or {}).get(src) is not None:
1819
+ entry["eventSchema"][dst] = describe[src]
1820
+ return entry
1821
+
1822
+
1823
+ def _describe_curated_events(entries, is_cache_dir, concurrency):
1824
+ """Give each CURATED event its own schema, in place.
1825
+
1826
+ Curated events carry a baked object, so there is nothing to expand — but
1827
+ their vocabulary still has to come from `triggers describe` rather than
1828
+ from the node manifest. Called WITHOUT a connection: what comes back is
1829
+ the tenant-agnostic baseline, which is exactly what a baked library should
1830
+ carry. A connector's custom fields stay `prepare`'s job.
1831
+ """
1832
+ if not entries:
1833
+ return
1834
+ def work(entry):
1835
+ key = entry["connector"]["key"]
1836
+ operation = entry["operation"].get("eventOperation") or ""
1837
+ object_name = entry["operation"].get("objectName") or ""
1838
+ if not operation or not object_name:
1839
+ return entry, None, None
1840
+ rows = triggers_objects_cached(key, operation, is_cache_dir)
1841
+ obj = next((r for r in rows if r.get("Name") == object_name), None)
1842
+ return (entry,
1843
+ triggers_describe_cached(key, operation, object_name, is_cache_dir),
1844
+ obj)
1845
+
1846
+ described = 0
1847
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
1848
+ for entry, describe, obj in pool.map(work, entries):
1849
+ if not describe:
1850
+ continue
1851
+ _apply_event_schema(entry, describe, obj)
1852
+ described += 1
1853
+ print(f" {described}/{len(entries)} curated events described "
1854
+ f"(subscription parameters, filter fields, payload)")
1855
+
1856
+
1857
+ def _make_expanded_event_entry(template, obj, describe):
1858
+ """One per-object variant of a generic connector EVENT.
1859
+
1860
+ The variant keeps `activityType: GenericTrigger` deliberately. That value
1861
+ is what tells the SDK an event watches ONE object of the connection — it
1862
+ drives `check`'s EVENT_GENERIC_NO_OBJECT and serialize's refusal to emit
1863
+ an empty `objectName` — so renaming it the way action expansion renames
1864
+ `Generic` to `GenericExpanded` would silently switch both off. The object
1865
+ is carried on `operation.objectName`, which is also the index's variant
1866
+ key, exactly as for an expanded action.
1867
+ """
1868
+ entry = json.loads(json.dumps(template)) # deep copy
1869
+ object_name = obj.get("Name") or ""
1870
+ object_display = obj.get("DisplayName") or object_name
1871
+
1872
+ entry["operation"]["objectName"] = object_name
1873
+ entry["operation"]["objectDisplayName"] = object_display
1874
+ entry["operation"]["sourceNodeType"] = template["nodeType"]
1875
+ label = (template.get("display") or {}).get("label") or ""
1876
+ entry["display"]["label"] = f"{label} ({object_display})".strip()
1877
+
1878
+ # `eventSchema` is stamped onto the sidecar by `write_library`, which is
1879
+ # where the SDK reads a subscription's vocabulary from.
1880
+ return _apply_event_schema(entry, describe, obj)
1881
+
1882
+
1883
+ def _expand_generic_events(templates, is_cache_dir, concurrency):
1884
+ """Expand each generic EVENT template into one entry per event object."""
1885
+ if not templates:
1886
+ return []
1887
+ work = []
1888
+ for template in templates:
1889
+ key = template["connector"]["key"]
1890
+ operation = template["operation"].get("eventOperation") or ""
1891
+ if not operation:
1892
+ continue
1893
+ for obj in triggers_objects_cached(key, operation, is_cache_dir):
1894
+ if obj.get("Name"):
1895
+ work.append((template, key, operation, obj))
1896
+ if not work:
1897
+ return []
1898
+
1899
+ expanded = []
1900
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
1901
+ futures = {
1902
+ pool.submit(triggers_describe_cached, key, operation,
1903
+ obj["Name"], is_cache_dir): (template, obj)
1904
+ for template, key, operation, obj in work
1905
+ }
1906
+ for future in as_completed(futures):
1907
+ template, obj = futures[future]
1908
+ expanded.append(
1909
+ _make_expanded_event_entry(template, obj, future.result() or {}))
1910
+ print(f" {len(expanded)} generic EVENT variants expanded from "
1911
+ f"{len(templates)} template(s)")
1912
+ return expanded
1913
+
1914
+
1915
+ def resolve_connector_events(entries, is_cache_dir, concurrency):
1916
+ """Give every connector EVENT its real schema; expand the expandable ones.
1917
+
1918
+ Deliberately NOT part of `expand_all_generics`: that function answers a
1919
+ question about Generic CRUD ACTIONS and is skipped by
1920
+ `--skip-generic-expansion`, and an event's schema has nothing to do with
1921
+ that flag. Returns `(entries, expanded)` where `entries` is the input with
1922
+ curated events described in place, and `expanded` holds the per-object
1923
+ variants of the generic events whose objects do not come from the
1924
+ connection.
1925
+ """
1926
+ os.makedirs(is_cache_dir, exist_ok=True)
1927
+ curated, generic = [], []
1928
+ for e in entries:
1929
+ at = e["operation"].get("activityType", "")
1930
+ if at == "CuratedTrigger":
1931
+ # Not expanded — its object is baked in — but its schema still
1932
+ # comes from the event, not from the node manifest.
1933
+ curated.append(e)
1934
+ elif at == "GenericTrigger":
1935
+ # UNKNOWN is not "static". `objects_come_from_the_connection`
1936
+ # reads two flags that are absent whenever connector enrichment
1937
+ # was skipped or failed, and absent reads as False — so a
1938
+ # connector whose objects DO come from the connection would be
1939
+ # expanded with whatever the connection-free listing returns
1940
+ # (Salesforce: its 4 declared objects, not the tenant's 1,178),
1941
+ # and the library would then advertise those four as the whole
1942
+ # catalog. Expand only where the connector positively says its
1943
+ # objects do not come from the connection.
1944
+ discovery = (e.get("connector") or {}).get("discovery")
1945
+ if not discovery:
1946
+ print(f" WARN: {e['nodeType']} not expanded — no connector "
1947
+ f"discovery metadata, so whether its objects come from "
1948
+ f"the connection is unknown", file=sys.stderr)
1949
+ elif not objects_come_from_the_connection(e):
1950
+ generic.append(e)
1951
+ if not curated and not generic:
1952
+ return entries, []
1953
+ _describe_curated_events(curated, is_cache_dir, concurrency)
1954
+ return entries, _expand_generic_events(generic, is_cache_dir, concurrency)
1955
+
1956
+
1957
+ def expand_all_generics(entries, is_cache_dir, concurrency):
1958
+ """Expand every Generic entry into per-(object, op) entries.
1959
+
1960
+ Returns (curated_and_passthrough_entries, expanded_entries).
1961
+ Curated entries pass through untouched; HTTP-passthrough Generic
1962
+ entries pass through as one-per-connector library entries; CRUD Generic
1963
+ entries are dropped from the output in favor of their expansions.
1964
+ """
1965
+ os.makedirs(is_cache_dir, exist_ok=True)
1966
+
1967
+ curated_and_passthrough = []
1968
+ generic_crud = []
1969
+ discovery_entries = 0
1970
+ for e in entries:
1971
+ at = e["operation"].get("activityType", "")
1972
+ if at == "Generic":
1973
+ if _is_http_passthrough(e):
1974
+ curated_and_passthrough.append(e)
1975
+ elif objects_come_from_the_connection(e):
1976
+ curated_and_passthrough.append(as_discovery_entry(e))
1977
+ discovery_entries += 1
1978
+ else:
1979
+ generic_crud.append(e)
1980
+ elif at == "GenericTrigger":
1981
+ # A generic connector EVENT poses the same question one level up —
1982
+ # one node type covering every object of the connection — and the
1983
+ # SAME connector flags answer it. Verified against the platform:
1984
+ # outlook (`hasCustomObjectDiscovery: false`) answers `is triggers
1985
+ # objects CREATED` with Message + Calendar whether or not a
1986
+ # connection is passed, so those variants are the same for every
1987
+ # tenant and bake offline; Salesforce (`true`) answers 4 objects
1988
+ # offline and 1,178 against a connection, so it stays a template
1989
+ # the author names an object on and `prepare` materializes.
1990
+ curated_and_passthrough.append(e)
1991
+ else:
1992
+ # Curated or anything else — pass through.
1993
+ curated_and_passthrough.append(e)
1994
+
1995
+ disambiguate_discovery_labels(curated_and_passthrough)
1996
+
1997
+ templates = _generic_templates_by_op(generic_crud)
1998
+ print(f" {len(generic_crud)} Generic CRUD entries across {len(templates)} connectors")
1999
+ print(f" {discovery_entries} kept unexpanded: objects come from the connection")
2000
+ print(f" {sum(1 for e in entries if _is_http_passthrough(e))} HTTP-passthrough entries (kept as-is)")
2001
+
2002
+ expanded = []
2003
+ if not templates:
2004
+ return curated_and_passthrough, expanded
2005
+
2006
+ # Use the same global worker limit for each remote-call phase. Submitting
2007
+ # one future per connector leaves the pool mostly idle when a connector
2008
+ # such as Salesforce or ServiceNow has a much larger object catalog than
2009
+ # the others. Flattening object and operation work prevents that long tail
2010
+ # without creating nested pools that could multiply the concurrency cap.
2011
+ connector_objects = []
2012
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
2013
+ futures = {
2014
+ pool.submit(_list_generic_objects, k, by_method, is_cache_dir): k
2015
+ for k, by_method in templates.items()
2016
+ }
2017
+ done = 0
2018
+ for fut in as_completed(futures):
2019
+ k = futures[fut]
2020
+ done += 1
2021
+ try:
2022
+ connector_objects.extend(
2023
+ (k, obj) for obj in fut.result()
2024
+ )
2025
+ except Exception as exc:
2026
+ print(f" WARN: object listing failed for {k}: {exc}",
2027
+ file=sys.stderr)
2028
+ if done % 25 == 0 or done == len(templates):
2029
+ print(f" Listed objects for {done}/{len(templates)} connectors "
2030
+ f"({len(connector_objects)} objects so far)")
2031
+
2032
+ operation_candidates = []
2033
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
2034
+ futures = {
2035
+ pool.submit(is_describe_object, k, obj["Name"], is_cache_dir):
2036
+ (k, obj)
2037
+ for k, obj in connector_objects
2038
+ }
2039
+ done = 0
2040
+ for fut in as_completed(futures):
2041
+ k, obj = futures[fut]
2042
+ done += 1
2043
+ try:
2044
+ obj_data = fut.result()
2045
+ except Exception as exc:
2046
+ obj_data = None
2047
+ print(f" WARN: object describe failed for {k}/"
2048
+ f"{obj['Name']}: {exc}", file=sys.stderr)
2049
+ if obj_data:
2050
+ available = (
2051
+ _pick(obj_data, "AvailableOperations", "availableOperations")
2052
+ or []
2053
+ )
2054
+ seen_op_names = set()
2055
+ for op in available:
2056
+ op_name = _pick(op, "Name", "name")
2057
+ if not op_name or op_name in seen_op_names:
2058
+ continue
2059
+ seen_op_names.add(op_name)
2060
+ template = templates[k].get(op_name) if op_name else None
2061
+ if template:
2062
+ operation_candidates.append((k, obj, op, template))
2063
+ if done % 100 == 0 or done == len(connector_objects):
2064
+ print(f" Described {done}/{len(connector_objects)} objects "
2065
+ f"({len(operation_candidates)} operations so far)")
2066
+
2067
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
2068
+ futures = {
2069
+ pool.submit(
2070
+ is_describe_operation,
2071
+ k,
2072
+ obj["Name"],
2073
+ _pick(op, "Name", "name"),
2074
+ is_cache_dir,
2075
+ ): (k, obj, op, template)
2076
+ for k, obj, op, template in operation_candidates
2077
+ }
2078
+ done = 0
2079
+ for fut in as_completed(futures):
2080
+ k, obj, op, template = futures[fut]
2081
+ done += 1
2082
+ try:
2083
+ op_data = fut.result()
2084
+ except Exception as exc:
2085
+ op_data = None
2086
+ print(f" WARN: operation describe failed for {k}/"
2087
+ f"{obj['Name']}/{_pick(op, 'Name', 'name')}: {exc}",
2088
+ file=sys.stderr)
2089
+ expanded.append(_make_expanded_entry(template, obj, op, op_data))
2090
+ if done % 100 == 0 or done == len(operation_candidates):
2091
+ print(f" Expanded {done}/{len(operation_candidates)} "
2092
+ "object operations")
2093
+
2094
+ schema_dynamic = sum(
2095
+ 1 for e in expanded
2096
+ if e["runtime"].get("requiresConnectionForSchema")
2097
+ )
2098
+ print(f" expanded {len(expanded)} entries from {len(generic_crud)} Generic CRUD actions")
2099
+ print(f" schema-dynamic among expanded: {schema_dynamic}/{len(expanded)}")
2100
+ return curated_and_passthrough, expanded
2101
+
2102
+
2103
+ # ---------------------------------------------------------------------------
2104
+ # Library output
2105
+ # ---------------------------------------------------------------------------
2106
+
2107
+ def _action_id_from_node_type(node_type):
2108
+ """Extract the action segment from a uipath.connector.<key>.<action> nodeType."""
2109
+ parts = node_type.split(".")
2110
+ return parts[-1] if len(parts) >= 4 else node_type.replace(".", "-")
2111
+
2112
+
2113
+ def _safe_path_segment(s):
2114
+ """Sanitize a string for use as one path segment."""
2115
+ return re.sub(r"[^a-zA-Z0-9_.\-]+", "_", s) or "_"
2116
+
2117
+
2118
+ def _input_field_count(entry):
2119
+ """How much of a schema an entry carries — the tie-break when two claim one
2120
+ path, since whichever loses is dropped from the catalogue entirely."""
2121
+ return len(((entry.get("inputSchema") or {}).get("fields")) or [])
2122
+
2123
+
2124
+ def _entry_relpath(entry):
2125
+ """Where to write `entry` relative to the library root.
2126
+
2127
+ Returns the JSON path (sans `.json`); add `.json` / `.v1def.json` to get
2128
+ the final filenames. Expanded Generic entries land under
2129
+ `<connector>/generic/<object>/<op>@<ver>` so the original curated +
2130
+ HTTP-passthrough files stay at the connector top level.
2131
+ """
2132
+ connector_key = entry["connector"]["key"] or "unknown"
2133
+ version = entry["version"] or "0.0.0"
2134
+ if entry["operation"].get("activityType") == "GenericExpanded":
2135
+ obj = _safe_path_segment(entry["operation"]["objectName"] or "object")
2136
+ op = _safe_path_segment((entry["operation"]["name"] or "op").lower())
2137
+ return os.path.join(connector_key, "generic", obj, f"{op}@{version}")
2138
+ action_id = _action_id_from_node_type(entry["nodeType"])
2139
+ # An expanded generic EVENT keeps its template's nodeType (that value is
2140
+ # load-bearing for the SDK — see `_make_expanded_event_entry`), so the
2141
+ # action id alone is the SAME path for every object and the variants would
2142
+ # overwrite each other down to one. The object is what separates them,
2143
+ # exactly as it does for an expanded action.
2144
+ if (entry["operation"].get("activityType") == "GenericTrigger"
2145
+ and entry["operation"].get("objectName")):
2146
+ obj = _safe_path_segment(entry["operation"]["objectName"])
2147
+ return os.path.join(connector_key, "events", obj, f"{action_id}@{version}")
2148
+ return os.path.join(connector_key, f"{action_id}@{version}")
2149
+
2150
+
2151
+ def _build_v1_definition(node_data):
2152
+ """Project a registry `Data.Node` into the shape the Flow .flow file
2153
+ expects under definitions[]. The registry response is a near-superset:
2154
+ drop fields the Flow def doesn't carry (`connectorMethodInfo`,
2155
+ `outputResponseDefinition`) and add three standard fields the editor
2156
+ stamps on every connector definition (`supportsErrorHandling`,
2157
+ `inputDefaults`, `debug`).
2158
+ """
2159
+ keep = {
2160
+ k: v for k, v in node_data.items()
2161
+ if k not in ("connectorMethodInfo", "outputResponseDefinition")
2162
+ }
2163
+ keep.setdefault("supportsErrorHandling", True)
2164
+ keep.setdefault("inputDefaults", {})
2165
+ keep.setdefault("debug", {"runtime": "bpmnEngine"})
2166
+ return keep
2167
+
2168
+
2169
+ def _load_node_data(json_path):
2170
+ """Pull `Data.Node` from a cached `flow registry get` response."""
2171
+ with open(json_path) as f:
2172
+ d = json.load(f)
2173
+ return d.get("Data", {}).get("Node")
2174
+
2175
+
2176
+ def write_library(output_dir, entries, v1def_source_paths):
2177
+ """Write one JSON file per (connector, action, version) plus a sibling
2178
+ `.v1def.json` carrying the Flow definitions[] shape, plus an index.json.
2179
+
2180
+ `v1def_source_paths` maps nodeType → path of the cached registry JSON
2181
+ so we can reach back to the raw `Data.Node` at write time.
2182
+ """
2183
+ if os.path.exists(output_dir):
2184
+ shutil.rmtree(output_dir)
2185
+ os.makedirs(output_dir, exist_ok=True)
2186
+
2187
+ index = []
2188
+ written = 0
2189
+ claimed = {}
2190
+
2191
+ for entry in entries:
2192
+ connector_key = entry["connector"]["key"] or "unknown"
2193
+ version = entry["version"] or "0.0.0"
2194
+
2195
+ relpath = _entry_relpath(entry) # no extension
2196
+ # Two entries can land on one path, and the second write used to
2197
+ # silently overwrite the first — leaving index.json one row longer than
2198
+ # the directory. In the tier-1 catalogue it happens once, and not
2199
+ # because of a duplicate: CURATED_OP_OVERRIDE renames Outlook365's
2200
+ # multipart `ReplyToEmailV2` to `ReplyToEmail`, which is also a real
2201
+ # object of its own, so the renamed entry (6 fields, still pointing at
2202
+ # `/ReplyToEmailV2`) collides with the genuine one (10 fields).
2203
+ #
2204
+ # One of them has to go. Prefer the entry with more input fields rather
2205
+ # than whichever the iteration reached first: the thinner one loses
2206
+ # less, and the choice does not flip when the object list is reordered.
2207
+ previous = claimed.get(relpath)
2208
+ if previous is not None:
2209
+ if previous == entry:
2210
+ continue
2211
+ if _input_field_count(entry) <= _input_field_count(previous):
2212
+ print(f" WARN: {relpath} claimed by two differing entries; "
2213
+ f"keeping the one with more inputs "
2214
+ f"({_input_field_count(previous)} vs "
2215
+ f"{_input_field_count(entry)})", file=sys.stderr)
2216
+ continue
2217
+ # This one is richer — drop the row already recorded for it.
2218
+ print(f" WARN: {relpath} claimed by two differing entries; "
2219
+ f"replacing the thinner one "
2220
+ f"({_input_field_count(previous)} -> "
2221
+ f"{_input_field_count(entry)})", file=sys.stderr)
2222
+ index[:] = [row for row in index if row["path"] != relpath + ".json"]
2223
+ written -= 1
2224
+ claimed[relpath] = entry
2225
+
2226
+ path = os.path.join(output_dir, relpath + ".json")
2227
+ os.makedirs(os.path.dirname(path), exist_ok=True)
2228
+
2229
+ with open(path, "w") as f:
2230
+ json.dump(entry, f, indent=2)
2231
+ f.write("\n")
2232
+ written += 1
2233
+
2234
+ # Flow definition sidecar — used when emitting a flow. Expanded
2235
+ # Generic entries reuse the source Generic action's `Data.Node` as
2236
+ # a v1def TEMPLATE; the converter is responsible for stamping the
2237
+ # entry's `operation.objectName` and inputSchema into the Flow node
2238
+ # at conversion time.
2239
+ v1def_lookup_key = (
2240
+ entry["operation"].get("sourceNodeType")
2241
+ if entry["operation"].get("activityType") == "GenericExpanded"
2242
+ else entry["nodeType"]
2243
+ )
2244
+ v1def_src = v1def_source_paths.get(v1def_lookup_key)
2245
+ if v1def_src:
2246
+ try:
2247
+ node_data = _load_node_data(v1def_src)
2248
+ if node_data:
2249
+ v1def = _build_v1_definition(node_data)
2250
+ # A Generic registry node is only a dispatch template. Its
2251
+ # connection/object-resolved body fields live on the
2252
+ # expanded entry, so stamp them into the sidecar consumed by
2253
+ # compile and TypeScript generation.
2254
+ #
2255
+ # A Curated node can also land here with no inputs at all.
2256
+ # When an operation takes only query/path/multipart
2257
+ # parameters, the registry leaves `inputDefinition` empty and
2258
+ # describes those inputs under
2259
+ # `connectorMethodInfo.parameters` — which
2260
+ # `_build_v1_definition` drops. The entry already carries
2261
+ # them, because building `inputSchema` falls back to
2262
+ # `parameters`, so fill the gap from there rather than ship a
2263
+ # definition with no inputs. Only fill when empty: a node
2264
+ # that has real `inputDefinition` fields keeps them.
2265
+ expanded = (
2266
+ entry["operation"].get("activityType") == "GenericExpanded"
2267
+ )
2268
+ resolved_fields = entry.get("inputSchema", {}).get("fields") or []
2269
+ # The entry's fields are the node's own fields plus whatever
2270
+ # the parameters filled in, so they are a superset of what a
2271
+ # verbatim projection would carry — prefer them whenever we
2272
+ # have any. An expanded Generic node stamps even an empty
2273
+ # list, because its template's fields belong to the dispatch
2274
+ # node rather than to this object.
2275
+ if expanded or resolved_fields:
2276
+ v1def.setdefault("inputDefinition", {})["fields"] = (
2277
+ resolved_fields
2278
+ )
2279
+ # A per-object EVENT variant carries its own subscription
2280
+ # vocabulary, and this sidecar is where the SDK reads one
2281
+ # (`Library.eventParameterFields` / `eventFilterFields`).
2282
+ # The template's registry node is the same for every
2283
+ # object, so without this stamp every variant would answer
2284
+ # with the template's sections — which for a generic event
2285
+ # are empty, and `check` would then refuse every filter
2286
+ # field the object actually has.
2287
+ event_schema = entry.get("eventSchema") or {}
2288
+ if event_schema:
2289
+ v1def["eventParameters"] = {
2290
+ "fields": event_schema.get("eventParameters") or []
2291
+ }
2292
+ v1def["filterFields"] = {
2293
+ "fields": event_schema.get("filterFields") or []
2294
+ }
2295
+ for fact in ("eventMode", "byoaConnection",
2296
+ "isWebhookUrlVisible"):
2297
+ if event_schema.get(fact) is not None:
2298
+ v1def[fact] = event_schema[fact]
2299
+ v1def_path = os.path.join(output_dir, relpath + ".v1def.json")
2300
+ with open(v1def_path, "w") as f:
2301
+ json.dump(v1def, f, indent=2)
2302
+ f.write("\n")
2303
+ except Exception as exc:
2304
+ print(f" WARN: v1def write failed for {entry['nodeType']}: {exc}",
2305
+ file=sys.stderr)
2306
+
2307
+ index.append({
2308
+ "nodeType": entry["nodeType"],
2309
+ "version": version,
2310
+ "connectorKey": connector_key,
2311
+ "label": entry["display"]["label"],
2312
+ "activityType": entry["operation"].get("activityType", ""),
2313
+ # `objectName` disambiguates expansions that share a nodeType
2314
+ # (e.g. `insert-record` × N objects). Curated entries usually have
2315
+ # an objectName too; those lookups omit objectName and
2316
+ # match by nodeType alone.
2317
+ "objectName": entry["operation"].get("objectName", "") or "",
2318
+ "operationName": entry["operation"].get("name", "") or "",
2319
+ "path": os.path.relpath(path, output_dir),
2320
+ })
2321
+ # `registry search` reads only this index, so a flag left in the entry
2322
+ # file is invisible to the one consumer that has to branch on it.
2323
+ # Emitted only when set, so every other row stays byte-identical.
2324
+ if entry["operation"].get("objectFromDiscovery"):
2325
+ index[-1]["objectFromDiscovery"] = True
2326
+
2327
+ # Sort index for stable diffs. Many expanded entries share the source
2328
+ # Generic nodeType, so include `path` to break ties.
2329
+ index.sort(key=lambda e: (e["connectorKey"], e["nodeType"], e["version"], e["path"]))
2330
+
2331
+ index_path = os.path.join(output_dir, "index.json")
2332
+ with open(index_path, "w") as f:
2333
+ json.dump({"schemaVersion": SCHEMA_VERSION, "entries": index}, f, indent=2)
2334
+ f.write("\n")
2335
+
2336
+ return written
2337
+
2338
+
2339
+ # ---------------------------------------------------------------------------
2340
+ # Main
2341
+ # ---------------------------------------------------------------------------
2342
+
2343
+ def main():
2344
+ args = parse_args()
2345
+
2346
+ print(f"Cache directory: {args.cache_dir}")
2347
+ print(f"Output directory: {args.output_dir}")
2348
+
2349
+ node_types = registry_search(args.cache_dir)
2350
+ print(f"Found {len(node_types)} connector node types")
2351
+
2352
+ connector_records = None
2353
+
2354
+ # Curated subset: keep the requested connectors' action AND connector-event
2355
+ # node types (`scoped_connector_key`). The START form
2356
+ # (`uipath.connector.trigger.<key>.<event>`) stays out — `registry get`
2357
+ # refuses it without a --connection-id, so including it would only buy a
2358
+ # flood of doomed calls, and the SDK derives that form from the wait
2359
+ # sibling this build does materialize.
2360
+ if args.connectors:
2361
+ allow = {c.strip() for c in args.connectors.split(",") if c.strip()}
2362
+ before = len(node_types)
2363
+ node_types = [
2364
+ nt for nt in node_types
2365
+ if scoped_connector_key(nt) in allow
2366
+ ]
2367
+ matched = {
2368
+ key for key in allow
2369
+ if any(f".{key}." in f"{nt}." for nt in node_types)
2370
+ }
2371
+ print(f"Curated subset: kept {len(node_types)}/{before} node types for "
2372
+ f"{len(matched)}/{len(allow)} connectors {sorted(matched)}")
2373
+ missing = allow - matched
2374
+ if missing:
2375
+ print(f" WARN: no registry nodes matched these connector keys: "
2376
+ f"{sorted(missing)}", file=sys.stderr)
2377
+ else:
2378
+ # A full build ships the PUBLISHED catalog, not everything the build
2379
+ # tenant happens to see.
2380
+ before = len(node_types)
2381
+ node_types, dropped = select_published_connectors(node_types)
2382
+ if dropped:
2383
+ print(f"Published catalog: kept {len(node_types)}/{before} node "
2384
+ f"types, dropped {len(dropped)} tenant-local connector(s) "
2385
+ f"{dropped}")
2386
+
2387
+ if args.catalog_scope in {"tier1", "tier2"}:
2388
+ requested_tier = args.catalog_scope
2389
+ tier_number = requested_tier[-1]
2390
+ scope_keys = {
2391
+ key for key in (scoped_connector_key(nt) for nt in node_types) if key
2392
+ }
2393
+ print(f"Resolving Tier {tier_number} scope from metadata for "
2394
+ f"{len(scope_keys)} connector keys...")
2395
+ connector_records = fetch_connector_records_for_keys(
2396
+ scope_keys, args.connectors_cache_dir, args.concurrency
2397
+ )
2398
+ missing_metadata = sorted(
2399
+ key for key, record in connector_records.items() if record is None
2400
+ )
2401
+ if missing_metadata:
2402
+ preview = ", ".join(missing_metadata[:10])
2403
+ remainder = (
2404
+ f" (+{len(missing_metadata) - 10} more)"
2405
+ if len(missing_metadata) > 10 else ""
2406
+ )
2407
+ sys.exit(
2408
+ f"Tier {tier_number} filtering requires connector metadata for every "
2409
+ f"candidate connector, but lookup failed for: {preview}{remainder}"
2410
+ )
2411
+ before = len(node_types)
2412
+ node_types, matching_keys = filter_node_types_by_tier(
2413
+ node_types, connector_records, requested_tier
2414
+ )
2415
+ if not matching_keys:
2416
+ sys.exit(
2417
+ f"Tier {tier_number} filtering selected no connectors. Expected "
2418
+ "the Tier field from `uip is connectors metadata` to contain "
2419
+ f"'{tier_number}'."
2420
+ )
2421
+ print(f"Tier {tier_number} scope: kept {len(node_types)}/{before} action "
2422
+ f"node types for {len(matching_keys)} connectors")
2423
+
2424
+ # Side-file: catalog every trigger nodeType the registry search exposed.
2425
+ # Triggers are NOT materialized into the main library because
2426
+ # `flow registry get <trigger>` requires --connection-id; this file is
2427
+ # the closest we get to a connection-less trigger inventory.
2428
+ triggers_output = args.triggers_output or os.path.join(
2429
+ os.path.dirname(os.path.normpath(args.output_dir)) or ".",
2430
+ "registry-triggers.json",
2431
+ )
2432
+ print(f"Writing trigger side-file → {triggers_output}")
2433
+ write_triggers_side_file(args.cache_dir, triggers_output)
2434
+
2435
+ print(f"Fetching connector details (concurrency={args.concurrency})...")
2436
+ cache_files = fetch_all(node_types, args.cache_dir, args.concurrency)
2437
+ print(f"Successfully fetched {len(cache_files)} connectors")
2438
+
2439
+ print("Extracting connector entries (Curated + Generic)...")
2440
+ entries = []
2441
+ v1def_source_paths = {}
2442
+ by_activity = {}
2443
+ for nt, path in sorted(cache_files.items()):
2444
+ try:
2445
+ entry = extract_canonical(path)
2446
+ if entry:
2447
+ entries.append(entry)
2448
+ v1def_source_paths[entry["nodeType"]] = path
2449
+ at = entry["operation"].get("activityType", "") or "(empty)"
2450
+ by_activity[at] = by_activity.get(at, 0) + 1
2451
+ except Exception as e:
2452
+ print(f" WARN: error processing {nt}: {e}", file=sys.stderr)
2453
+
2454
+ print(f"Found {len(entries)} entries total: " +
2455
+ ", ".join(f"{k}={v}" for k, v in sorted(by_activity.items())))
2456
+
2457
+ if not args.skip_enrichment:
2458
+ print(f"Enriching via `is resources describe` (concurrency={args.concurrency})...")
2459
+ entries = enrich_all(entries, args.is_cache_dir, args.concurrency)
2460
+
2461
+ if not args.skip_connector_enrichment:
2462
+ print(f"Enriching via `is connectors metadata` "
2463
+ f"(concurrency={args.concurrency})...")
2464
+ if connector_records is None:
2465
+ connector_records = fetch_connector_records(
2466
+ entries, args.connectors_cache_dir, args.concurrency
2467
+ )
2468
+ missing_metadata = sorted(
2469
+ key for key, record in connector_records.items() if record is None
2470
+ )
2471
+ if missing_metadata:
2472
+ preview = ", ".join(missing_metadata[:10])
2473
+ remainder = (
2474
+ f" (+{len(missing_metadata) - 10} more)"
2475
+ if len(missing_metadata) > 10 else ""
2476
+ )
2477
+ sys.exit(
2478
+ "Connector metadata is required to classify schema discovery, "
2479
+ "but `uip is connectors metadata` failed for: "
2480
+ f"{preview}{remainder}. Upgrade the UiPath CLI or rerun after "
2481
+ "checking authentication."
2482
+ )
2483
+ named = 0
2484
+ for e in entries:
2485
+ ck = e["connector"]["key"]
2486
+ rec = connector_records.get(ck)
2487
+ merge_connector_record(e, rec)
2488
+ if e["connector"].get("name"):
2489
+ named += 1
2490
+ schema_dynamic = sum(
2491
+ 1 for e in entries
2492
+ if e["runtime"].get("requiresConnectionForSchema")
2493
+ )
2494
+ print(f" Named {named}/{len(entries)} entries from connector records")
2495
+ print(f" schema-dynamic from connector capabilities: "
2496
+ f"{schema_dynamic}/{len(entries)}")
2497
+
2498
+ if not args.skip_generic_expansion:
2499
+ print(f"Expanding Generic CRUD by (object, operation) "
2500
+ f"(concurrency={args.concurrency})...")
2501
+ kept, expanded = expand_all_generics(entries, args.is_cache_dir, args.concurrency)
2502
+ entries = kept + expanded
2503
+
2504
+ print(f"Resolving connector events (concurrency={args.concurrency})...")
2505
+ entries, expanded_events = resolve_connector_events(
2506
+ entries, args.is_cache_dir, args.concurrency)
2507
+ entries = entries + expanded_events
2508
+
2509
+ # Curated op remaps (e.g. Outlook ReplyToEmailV2 → dispatchable ReplyToEmail) — applied
2510
+ # last so they override anything enrichment/expansion produced.
2511
+ entries = apply_curated_overrides(entries)
2512
+
2513
+ print("Writing canonical library...")
2514
+ written = write_library(args.output_dir, entries, v1def_source_paths)
2515
+ print(f"Wrote {written} library entries + index.json")
2516
+
2517
+ if not args.keep_temp:
2518
+ print("Cleaning up caches...")
2519
+ shutil.rmtree(args.cache_dir, ignore_errors=True)
2520
+ shutil.rmtree(args.is_cache_dir, ignore_errors=True)
2521
+ shutil.rmtree(args.connectors_cache_dir, ignore_errors=True)
2522
+
2523
+ print("Done!")
2524
+
2525
+
2526
+ if __name__ == "__main__":
2527
+ main()