@uipath/maestro-builder-sdk 6.3.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/dist/api-index.md +6 -6
  2. package/dist/cli/commands/registry.js +68 -12
  3. package/dist/registry/archive/entry-policy.d.ts +36 -0
  4. package/dist/registry/archive/entry-policy.js +60 -0
  5. package/dist/registry/archive/extract.d.ts +31 -0
  6. package/dist/registry/archive/extract.js +92 -0
  7. package/dist/registry/archive/zip-reader.d.ts +38 -0
  8. package/dist/registry/archive/zip-reader.js +256 -0
  9. package/dist/registry/codecs/pyjson.d.ts +62 -0
  10. package/dist/registry/codecs/pyjson.js +208 -0
  11. package/dist/registry/compat/compare.d.ts +18 -0
  12. package/dist/registry/compat/compare.js +32 -0
  13. package/dist/registry/compat/paths.d.ts +4 -0
  14. package/dist/registry/compat/paths.js +17 -0
  15. package/dist/registry/compat/pystr.d.ts +50 -0
  16. package/dist/registry/compat/pystr.js +221 -0
  17. package/dist/registry/concurrency.d.ts +10 -0
  18. package/dist/registry/concurrency.js +34 -0
  19. package/dist/registry/curated-overlay.d.ts +36 -4
  20. package/dist/registry/curated-overlay.js +87 -14
  21. package/dist/registry/errors.d.ts +26 -0
  22. package/dist/registry/errors.js +38 -0
  23. package/dist/registry/extractor.d.ts +9 -10
  24. package/dist/registry/extractor.js +5 -15
  25. package/dist/registry/files.d.ts +26 -0
  26. package/dist/registry/files.js +160 -0
  27. package/dist/registry/generators/descriptors.d.ts +81 -0
  28. package/dist/registry/generators/descriptors.js +405 -0
  29. package/dist/registry/generators/fields.d.ts +46 -0
  30. package/dist/registry/generators/fields.js +111 -0
  31. package/dist/registry/generators/markdown/common.d.ts +21 -0
  32. package/dist/registry/generators/markdown/common.js +55 -0
  33. package/dist/registry/generators/markdown/index.d.ts +33 -0
  34. package/dist/registry/generators/markdown/index.js +37 -0
  35. package/dist/registry/generators/markdown/page.d.ts +8 -0
  36. package/dist/registry/generators/markdown/page.js +52 -0
  37. package/dist/registry/generators/markdown/pyvalue.d.ts +34 -0
  38. package/dist/registry/generators/markdown/pyvalue.js +106 -0
  39. package/dist/registry/generators/markdown/sections/choosing-object.d.ts +2 -0
  40. package/dist/registry/generators/markdown/sections/choosing-object.js +52 -0
  41. package/dist/registry/generators/markdown/sections/example.d.ts +22 -0
  42. package/dist/registry/generators/markdown/sections/example.js +295 -0
  43. package/dist/registry/generators/markdown/sections/filtering.d.ts +2 -0
  44. package/dist/registry/generators/markdown/sections/filtering.js +76 -0
  45. package/dist/registry/generators/markdown/sections/header.d.ts +2 -0
  46. package/dist/registry/generators/markdown/sections/header.js +37 -0
  47. package/dist/registry/generators/markdown/sections/inputs.d.ts +30 -0
  48. package/dist/registry/generators/markdown/sections/inputs.js +151 -0
  49. package/dist/registry/generators/markdown/sections/lookups.d.ts +12 -0
  50. package/dist/registry/generators/markdown/sections/lookups.js +150 -0
  51. package/dist/registry/generators/markdown/sections/outputs.d.ts +3 -0
  52. package/dist/registry/generators/markdown/sections/outputs.js +9 -0
  53. package/dist/registry/generators/markdown/sections/resolving-fields.d.ts +2 -0
  54. package/dist/registry/generators/markdown/sections/resolving-fields.js +139 -0
  55. package/dist/registry/generators/markdown/sections/schema-dynamic.d.ts +2 -0
  56. package/dist/registry/generators/markdown/sections/schema-dynamic.js +86 -0
  57. package/dist/registry/generators/materialize.d.ts +30 -0
  58. package/dist/registry/generators/materialize.js +132 -0
  59. package/dist/registry/generators/md-symbols.d.ts +22 -0
  60. package/dist/registry/generators/md-symbols.js +58 -0
  61. package/dist/registry/generators/triggers.d.ts +15 -0
  62. package/dist/registry/generators/triggers.js +85 -0
  63. package/dist/registry/generators/type-registry.d.ts +50 -0
  64. package/dist/registry/generators/type-registry.js +154 -0
  65. package/dist/registry/generators/type-tree.d.ts +65 -0
  66. package/dist/registry/generators/type-tree.js +179 -0
  67. package/dist/registry/library-build/build.d.ts +57 -0
  68. package/dist/registry/library-build/build.js +286 -0
  69. package/dist/registry/library-build/canonical.d.ts +119 -0
  70. package/dist/registry/library-build/canonical.js +512 -0
  71. package/dist/registry/library-build/cli.d.ts +45 -0
  72. package/dist/registry/library-build/cli.js +182 -0
  73. package/dist/registry/library-build/context.d.ts +107 -0
  74. package/dist/registry/library-build/context.js +267 -0
  75. package/dist/registry/library-build/enrich.d.ts +36 -0
  76. package/dist/registry/library-build/enrich.js +158 -0
  77. package/dist/registry/library-build/events.d.ts +108 -0
  78. package/dist/registry/library-build/events.js +476 -0
  79. package/dist/registry/library-build/generic-expand.d.ts +77 -0
  80. package/dist/registry/library-build/generic-expand.js +363 -0
  81. package/dist/registry/library-build/plan.d.ts +71 -0
  82. package/dist/registry/library-build/plan.js +270 -0
  83. package/dist/registry/library-build/py.d.ts +45 -0
  84. package/dist/registry/library-build/py.js +167 -0
  85. package/dist/registry/library-build/schema-action.d.ts +38 -0
  86. package/dist/registry/library-build/schema-action.js +107 -0
  87. package/dist/registry/library-build/write.d.ts +6 -0
  88. package/dist/registry/library-build/write.js +26 -0
  89. package/dist/registry/library-reader.d.ts +20 -0
  90. package/dist/registry/library-reader.js +50 -0
  91. package/dist/registry/lookup-specs.d.ts +55 -0
  92. package/dist/registry/lookup-specs.js +140 -0
  93. package/dist/registry/materialize-cli.js +28 -14
  94. package/dist/registry/materializer.d.ts +25 -3
  95. package/dist/registry/materializer.js +45 -29
  96. package/dist/registry/model/library.d.ts +254 -0
  97. package/dist/registry/model/library.js +23 -0
  98. package/dist/registry/naming.d.ts +54 -0
  99. package/dist/registry/naming.js +142 -0
  100. package/dist/registry/orphans.d.ts +4 -0
  101. package/dist/registry/orphans.js +86 -0
  102. package/dist/registry/prepare/actions.d.ts +26 -0
  103. package/dist/registry/prepare/actions.js +94 -0
  104. package/dist/registry/prepare/connections.d.ts +181 -0
  105. package/dist/registry/prepare/connections.js +399 -0
  106. package/dist/registry/prepare/event.d.ts +96 -0
  107. package/dist/registry/prepare/event.js +268 -0
  108. package/dist/registry/prepare/handoff.d.ts +21 -0
  109. package/dist/registry/prepare/handoff.js +91 -0
  110. package/dist/registry/prepare/ledger.d.ts +104 -0
  111. package/dist/registry/prepare/ledger.js +216 -0
  112. package/dist/registry/prepare/messages.d.ts +190 -0
  113. package/dist/registry/prepare/messages.js +472 -0
  114. package/dist/registry/prepare/objects.d.ts +130 -0
  115. package/dist/registry/prepare/objects.js +318 -0
  116. package/dist/registry/prepare/overlay.d.ts +149 -0
  117. package/dist/registry/prepare/overlay.js +487 -0
  118. package/dist/registry/prepare/parents.d.ts +41 -0
  119. package/dist/registry/prepare/parents.js +122 -0
  120. package/dist/registry/prepare/pipeline.d.ts +91 -0
  121. package/dist/registry/prepare/pipeline.js +376 -0
  122. package/dist/registry/prepare/regenerate.d.ts +9 -0
  123. package/dist/registry/prepare/regenerate.js +50 -0
  124. package/dist/registry/prepare/resolve.d.ts +199 -0
  125. package/dist/registry/prepare/resolve.js +470 -0
  126. package/dist/registry/prepare/retry.d.ts +83 -0
  127. package/dist/registry/prepare/retry.js +109 -0
  128. package/dist/registry/prepare/source-import.d.ts +54 -0
  129. package/dist/registry/prepare/source-import.js +192 -0
  130. package/dist/registry/prepare/support.d.ts +41 -0
  131. package/dist/registry/prepare/support.js +178 -0
  132. package/dist/registry/prepare-cli.d.ts +16 -5
  133. package/dist/registry/prepare-cli.js +60 -53
  134. package/dist/registry/pull-cli.js +8 -7
  135. package/dist/registry/reporter.d.ts +26 -0
  136. package/dist/registry/reporter.js +41 -0
  137. package/dist/registry/uip/client.d.ts +99 -0
  138. package/dist/registry/uip/client.js +226 -0
  139. package/dist/registry/uip/commands.d.ts +40 -0
  140. package/dist/registry/uip/commands.js +80 -0
  141. package/dist/registry/uip/envelope.d.ts +37 -0
  142. package/dist/registry/uip/envelope.js +84 -0
  143. package/dist/registry/uip/response-cache.d.ts +28 -0
  144. package/dist/registry/uip/response-cache.js +57 -0
  145. package/lib/README.md +10 -8
  146. package/package.json +3 -3
  147. package/dist/generators/_connections.py +0 -331
  148. package/dist/generators/_events.py +0 -355
  149. package/dist/generators/_lookups.py +0 -114
  150. package/dist/generators/_naming.py +0 -185
  151. package/dist/generators/_objects.py +0 -299
  152. package/dist/generators/_resolve.py +0 -319
  153. package/dist/generators/convert_library_to_md.py +0 -1441
  154. package/dist/generators/extract_library.py +0 -125
  155. package/dist/generators/generate_connectors_ts.py +0 -936
  156. package/dist/generators/generate_library_json.py +0 -2527
  157. package/dist/generators/generate_triggers_ts.py +0 -110
  158. package/dist/generators/materialize_registry.py +0 -155
  159. package/dist/generators/prepare_connector.py +0 -1860
  160. package/dist/registry/python.d.ts +0 -26
  161. package/dist/registry/python.js +0 -46
@@ -1,1860 +0,0 @@
1
- #!/usr/bin/env python3
2
- """prepare-connector — fetch ONE connector op's FULL, connection-resolved schema
3
- into an ephemeral `./connectors-local/` overlay + a typed `.ts` (C4 / design §E).
4
-
5
- The baked library is tenant-agnostic, so schema-dynamic ops (Jira create-issue,
6
- Salesforce, …) ship only their connection-independent fields — the compiler then
7
- rejects the real fields (`fields.summary`, …) as unknown. `prepare-connector`
8
- closes that at author time: with a live connection it fetches the complete field
9
- schema for ONE op and writes the same artifacts the bulk generator produces, into
10
- a local overlay dir the compile CLIs auto-detect (`core/library.ts` overlay).
11
-
12
- prepare_connector.py <key> <action> --connection-id <id> [--object <name>]
13
- [--all-objects] [-f NAME=VALUE ...] [--out ./connectors-local]
14
-
15
- Schema-dynamic ops resolve their fields from the VALUES of a few parent fields,
16
- not from the connection alone, so `-f` is what actually materializes them (Jira
17
- create-issue: 2 fields with no `-f`, 19 with project + issue type, a different
18
- 22 for a different issue type). The values themselves have to be discovered
19
- first — that loop is the connector guide's, not this tool's.
20
-
21
- It's a single-op, connection-scoped slice of `generate_library_json.py`: it reuses
22
- that module's `registry_get` → `extract_canonical` → `enrich_entry` → `write_library`
23
- (routing all uip calls through `--connection-id` via `set_connection_id`), then
24
- runs `generate_connectors_ts.py` over the overlay. Calls ACCUMULATE — each prepared
25
- op merges into `--out` rather than replacing it.
26
-
27
- Needs a live connection + `uip`; it targets REAL/LIVE authoring (and the live eval
28
- rung), not the pure-offline arm. Custom (instance-specific) fields captured from
29
- one connection may not exist on another tenant at deploy — inherent to
30
- schema-dynamic connectors.
31
- """
32
- from __future__ import annotations
33
-
34
- import argparse
35
- import json
36
- import os
37
- import re
38
- import shutil
39
- import subprocess
40
- import sys
41
- import tempfile
42
- from pathlib import Path
43
-
44
- HERE = Path(__file__).resolve().parent
45
- sys.path.insert(0, str(HERE))
46
- import generate_library_json as gen # noqa: E402
47
- import generate_connectors_ts as gen_ts # noqa: E402
48
- import _resolve # noqa: E402
49
- import _connections # noqa: E402
50
- import _events # noqa: E402
51
- import _objects # noqa: E402
52
- from _lookups import load_invariant_collections # noqa: E402
53
-
54
-
55
- def _variant_key(entry: dict) -> tuple[str, str, str]:
56
- """Identity for one resolvable activity schema variant.
57
-
58
- Generic object variants intentionally share a nodeType, so keying only by
59
- nodeType makes the last prepared object overwrite every earlier one.
60
- """
61
- return (
62
- entry.get("nodeType", ""),
63
- entry.get("version", ""),
64
- entry.get("objectName", ""),
65
- )
66
-
67
-
68
- def _descriptor_overlay_path(path: str) -> str:
69
- parts = Path(path).parts
70
- if parts and parts[0] == "descriptors":
71
- return Path(*parts).as_posix()
72
- return (Path("descriptors") / Path(*parts)).as_posix()
73
-
74
-
75
- def _migrate_legacy_descriptor_roots(dst: str, entries: list[dict]) -> None:
76
- legacy_roots = {
77
- Path(entry["path"]).parts[0]
78
- for entry in entries
79
- if entry.get("path") and Path(entry["path"]).parts[0] != "descriptors"
80
- }
81
- for legacy_root in legacy_roots:
82
- source_root = Path(dst) / legacy_root
83
- if not source_root.is_dir():
84
- continue
85
- destination_root = Path(dst) / "descriptors" / legacy_root
86
- for root, dirs, files in os.walk(source_root, topdown=False):
87
- relative_root = Path(root).relative_to(source_root)
88
- destination = destination_root / relative_root
89
- destination.mkdir(parents=True, exist_ok=True)
90
- for name in files:
91
- os.replace(Path(root) / name, destination / name)
92
- for name in dirs:
93
- (Path(root) / name).rmdir()
94
- source_root.rmdir()
95
-
96
-
97
- def merge_into(src: str, dst: str) -> None:
98
- """Merge a freshly-written single-op library `src` into the overlay `dst`,
99
- accumulating index entries by operation/object variant and copying files."""
100
- os.makedirs(dst, exist_ok=True)
101
- with open(os.path.join(src, "index.json")) as src_index:
102
- src_idx = json.load(src_index)
103
- dst_idx_path = os.path.join(dst, "index.json")
104
- if os.path.exists(dst_idx_path):
105
- with open(dst_idx_path) as dst_index:
106
- dst_idx = json.load(dst_index)
107
- else:
108
- dst_idx = {"schemaVersion": gen.SCHEMA_VERSION, "entries": []}
109
- dst_entries = dst_idx.get("entries", [])
110
- _migrate_legacy_descriptor_roots(dst, dst_entries)
111
- for entry in dst_entries:
112
- entry["path"] = _descriptor_overlay_path(entry["path"])
113
- by_variant = {
114
- _variant_key(e): e for e in dst_entries
115
- }
116
- for e in src_idx.get("entries", []):
117
- # Re-preparing the same object refreshes it; preparing a different object
118
- # accumulates a sibling variant under the shared generic nodeType.
119
- e["path"] = _descriptor_overlay_path(e["path"])
120
- by_variant[_variant_key(e)] = e
121
- dst_idx["entries"] = sorted(
122
- by_variant.values(),
123
- key=lambda e: (
124
- e.get("connectorKey", ""),
125
- e.get("nodeType", ""),
126
- e.get("version", ""),
127
- e.get("objectName", ""),
128
- e.get("path", ""),
129
- ),
130
- )
131
- with open(dst_idx_path, "w") as f:
132
- json.dump(dst_idx, f, indent=2)
133
- for root, _dirs, files in os.walk(src):
134
- for name in files:
135
- if name == "index.json":
136
- continue
137
- rel = os.path.relpath(os.path.join(root, name), src)
138
- rel = _descriptor_overlay_path(rel)
139
- out = os.path.join(dst, rel)
140
- os.makedirs(os.path.dirname(out), exist_ok=True)
141
- shutil.copyfile(os.path.join(root, name), out)
142
-
143
-
144
- def _missing_generic_schema_message(
145
- operation: str, object_name: str | None = None
146
- ) -> str:
147
- target = (
148
- f'object "{object_name}" was not found or does not support action '
149
- f'"{operation}"'
150
- if object_name
151
- else f'no object in the connected catalog supports action "{operation}"'
152
- )
153
- return (
154
- f"prepare-connector: {target}. A connection-resolved schema requires a "
155
- "working live connection id. For an offline/validate-only flow that does "
156
- "not need connection-specific fields, use the baked connector descriptor "
157
- "without custom fields instead."
158
- )
159
-
160
-
161
- def _object_action_specs(registry_path: str) -> list[tuple[str, list[str]]]:
162
- """Return each api-type ObjectAction and its ordered parent-field names."""
163
- try:
164
- with open(registry_path) as registry_file:
165
- node = json.load(registry_file)["Data"]["Node"]
166
- actions = (
167
- (node.get("connectorMethodInfo") or {})
168
- .get("design") or {}
169
- ).get("actions") or []
170
- except (OSError, json.JSONDecodeError, KeyError, TypeError):
171
- return []
172
-
173
- specs = []
174
- for action in actions:
175
- if not isinstance(action, dict) or action.get("actionType") != "api":
176
- continue
177
- action_name = action.get("name")
178
- if not isinstance(action_name, str) or not action_name:
179
- continue
180
- refs = [
181
- rule.get("refFieldName")
182
- for rule in (action.get("rules") or [])
183
- if not rule.get("isCleared") and rule.get("refFieldName")
184
- ]
185
- if refs:
186
- specs.append((action_name, refs))
187
- return specs
188
-
189
-
190
- def _object_action_request(
191
- registry_path: str, parent_fields: dict[str, str]
192
- ) -> dict | None:
193
- """Build the design-time schema replay request for a prepared operation.
194
-
195
- The connection-resolved field list is only half of a schema-dynamic
196
- connector configuration. Studio Web also persists the ObjectAction and the
197
- parent values that produced that field list so it can replay the same
198
- schema. The registry owns the action name and rule order; ``-f`` owns the
199
- values.
200
- """
201
- if not parent_fields:
202
- return None
203
- for action_name, refs in _object_action_specs(registry_path):
204
- if not refs or set(refs) != set(parent_fields):
205
- continue
206
- return {
207
- "objectActionName": action_name,
208
- "parameterValues": [
209
- [name.replace(".", "_sub_"), parent_fields[name]]
210
- for name in refs
211
- ],
212
- }
213
- return None
214
- def _registry_actions(connector_key: str) -> list[str]:
215
- """The action ids the REGISTRY serves for one connector, newest catalog.
216
-
217
- `registry search` rather than a cached index: this runs only on the failure
218
- path, where the whole question is what the tenant actually serves.
219
- """
220
- result = subprocess.run(
221
- ["uip", "maestro", "flow", "registry", "search", connector_key, "--output", "json"],
222
- capture_output=True,
223
- text=True,
224
- )
225
- if result.returncode != 0:
226
- return []
227
- try:
228
- data = json.loads(result.stdout).get("Data") or []
229
- except json.JSONDecodeError:
230
- return []
231
- prefix = f"uipath.connector.{connector_key}."
232
- return sorted(
233
- {
234
- node_type[len(prefix):]
235
- for entry in data
236
- if isinstance(entry, dict)
237
- for node_type in [entry.get("NodeType") or ""]
238
- # Exact prefix only: the same search returns
239
- # `uipath.agent.resource.tool.connector.<key>.<action>` and
240
- # `uipath.connector.trigger.<key>.<event>`, which are not actions of
241
- # this node and would read as alternatives an author could pass.
242
- if node_type.startswith(prefix) and "." not in node_type[len(prefix):]
243
- }
244
- )
245
-
246
-
247
- def _closest_actions(action: str, candidates: list[str]) -> list[str]:
248
- """Candidates ranked by shared hyphen tokens, best first.
249
-
250
- Token overlap rather than edit distance because the two namespaces differ by
251
- whole words, not typos: the baked library's `list-all-records` against the
252
- registry's `list-records`.
253
- """
254
- wanted = set(action.split("-"))
255
- scored = [
256
- (len(wanted & set(c.split("-"))), -len(c), c)
257
- for c in candidates
258
- if wanted & set(c.split("-"))
259
- ]
260
- return [c for _, _, c in sorted(scored, reverse=True)]
261
-
262
-
263
- def _unresolved_action_message(connector_key: str, action: str, node_type: str) -> str:
264
- """What to do when the action id is not a registry key.
265
-
266
- NOT auto-substituted, deliberately. The baked action id and the registry key
267
- are different namespaces — a generic op is `list-all-records` in the library
268
- and materializes per object, while the registry serves `list-records` — and
269
- guessing between them would emit a node both offline validators accept and
270
- the runtime dispatches to the wrong operation. So this names the candidates
271
- and lets the author choose.
272
-
273
- The message earns its length: in the `generic-dynamic-node` eval the bare
274
- "check the key/action" version sent an agent to page 18,037 objects through
275
- `uip is resources list` for six turns instead of retrying with the right id.
276
- """
277
- actions = _registry_actions(connector_key)
278
- lines = [
279
- f"prepare-connector: `registry get {node_type}` found no such node.",
280
- "",
281
- "The baked library's action id is not always the registry's key — a Generic",
282
- "op is `list-all-records` in the library and materializes per object, while",
283
- "the registry serves the un-expanded form. Re-run with a registry action id.",
284
- ]
285
- if not actions:
286
- lines += [
287
- "",
288
- f"`registry search {connector_key}` returned nothing. If the catalog looks",
289
- "stale, refresh it first: `uip maestro flow registry pull --force`.",
290
- ]
291
- return "\n".join(lines)
292
- close = _closest_actions(action, actions)
293
- if close:
294
- lines += ["", f"Closest to \"{action}\": {', '.join(close[:3])}"]
295
- lines += ["", f"All actions the registry serves for {connector_key}:", *(f" {a}" for a in actions)]
296
- return "\n".join(lines)
297
-
298
-
299
- def main() -> None:
300
- ap = argparse.ArgumentParser(description="Prepare a connector op's full schema into ./connectors-local")
301
- ap.add_argument("key", help="connector key, e.g. uipath-atlassian-jira")
302
- ap.add_argument("action", help="op action id, e.g. create-issue")
303
- # OPTIONAL. `prepare` already knows the connector; making the author find
304
- # its connection id by hand is work the command can do from what it has.
305
- ap.add_argument(
306
- "--connection-id", default=None,
307
- help="a live connection id. Omit it and the connection is discovered "
308
- "from `uip is connections list` — your folder first, the tenant "
309
- "second — and its id AND folder key are written into bindings.json.")
310
- ap.add_argument(
311
- "--connection", default=None, metavar="NAME",
312
- help="pick the connection by name when several match. Several "
313
- "connections for one connector are NOT interchangeable, so an "
314
- "ambiguous match is an error rather than a guess.")
315
- ap.add_argument(
316
- "--bindings", default="./bindings.json",
317
- help="bindings file to add the discovered connection to "
318
- "(default: ./bindings.json). Additive and idempotent.")
319
- ap.add_argument(
320
- "--bind-connection", default=None, metavar="NAME",
321
- help="symbolic connection name to bind (default: the connector's last "
322
- "dash-segment, e.g. `slack` for uipath-salesforce-slack)")
323
- ap.add_argument(
324
- "--bind-folder", default="shared",
325
- help="symbolic folder name to bind (default: shared)")
326
- ap.add_argument(
327
- "--no-bindings", action="store_true",
328
- help="discover the connection but do not touch bindings.json")
329
- objects = ap.add_mutually_exclusive_group()
330
- objects.add_argument(
331
- "--object",
332
- help="expand one connection-discovered object for a Generic action",
333
- )
334
- objects.add_argument(
335
- "--all-objects",
336
- action="store_true",
337
- help="expand every connection-discovered object for a Generic action",
338
- )
339
- ap.add_argument(
340
- "-f", "--field", action="append", default=[], metavar="NAME=VALUE",
341
- help="a PARENT-field value that resolves the schema (repeatable), e.g. "
342
- "-f fields.project.key=IN -f fields.issuetype.id=10620. Pass every "
343
- "parent the operation declares: a partial set is rejected. See the "
344
- "connector guide's parent-field loop.",
345
- )
346
- ap.add_argument("--out", default="./connectors-local", help="overlay dir (default: ./connectors-local)")
347
- ap.add_argument("--source", default=None,
348
- help="the .flow.ts whose import should be repointed at the "
349
- "prepared descriptor (default: the one that imports "
350
- "this connector, when exactly one does)")
351
- ap.add_argument("--no-source-rewrite", action="store_true",
352
- help="print the import line instead of editing the source")
353
- ap.add_argument("--version", default=None, help="pin an op version (default: newest)")
354
- ap.add_argument("--import", dest="imp", default="../sdk/flow-sdk.js",
355
- help="module specifier for the emitted .ts (default: ../sdk/flow-sdk.js)")
356
- ap.add_argument(
357
- "--resolve", action="append", default=[], metavar="FIELD:BY=VALUE",
358
- help="resolve a lookup field to the id the connector wants, e.g. "
359
- "--resolve channel:profile.email=someone@example.com. Repeatable; "
360
- "order does not matter (a dependsOn prerequisite is resolved first). "
361
- "Results land in <out>/resolutions.json, which compile reads.")
362
- ap.add_argument(
363
- "--refresh-lookups", action="store_true",
364
- help="re-resolve lookups that are already recorded. Default is to leave "
365
- "them alone: a resolved id is a snapshot either way, and silently "
366
- "changing one during an unrelated prepare is worse than a stale one "
367
- "you can see in a diff.")
368
- ap.add_argument("--json-only", action="store_true",
369
- help="write the JSON overlay only — skip the typed .ts descriptor "
370
- "(the overlay alone already makes compile accept the fields; "
371
- "the A arm of the typed-vs-JSON study)")
372
- args = ap.parse_args()
373
-
374
- parent_fields = _parse_fields(args.field)
375
- # Set when this run must NOT overwrite an already-prepared schema; see
376
- # `_keep_prepared_schema`. Reported after the summary line, so the field
377
- # count it explains has already been printed.
378
- kept_schema: tuple[int, int] | None = None
379
-
380
- # Discover the connection when the caller did not name one, and record it in
381
- # bindings.json so the author never has to look up either key. This is the
382
- # step that removes 4-7 tool calls per task: listing connections, filtering
383
- # them, and then hunting Orchestrator for a folder key that was on the
384
- # connection record all along.
385
- # Whether the CALLER chose the connection, recorded before discovery
386
- # overwrites `connection_id` with its own answer. An explicit choice is never
387
- # second-guessed: the retry in `_write_resolutions` is for a connection this
388
- # command picked, not for one the author named (flow-builder-sdk#744).
389
- args._connection_pinned = bool(args.connection_id or args.connection)
390
-
391
- if not args.connection_id:
392
- try:
393
- found = _connections.discover_connection(args.key, args.connection)
394
- except _connections.ConnectionError_ as error:
395
- sys.exit(f"prepare-connector: {error}")
396
- args.connection_id = str(found["Id"])
397
- print(f" connection: {found.get('Name')} ({args.connection_id})")
398
- else:
399
- # An explicit id gets the same gate discovery applies. Without it a
400
- # `State: Failed` connection went straight to `is resources describe`,
401
- # which answers Success with NO request fields for a connection the
402
- # tenant cannot reach — so the overlay was written with zero body
403
- # inputs, reported as "0 input field(s)" success, and the next compile
404
- # rejected every real field as unknown (skill-flow-ixp-routing-negative/
405
- # sf-update: Salesforce update-record on a Failed connection, 0 fields;
406
- # the Enabled connection carries 13, StageName among them).
407
- try:
408
- found = _connections.lookup_connection(args.key, args.connection_id)
409
- except _connections.ConnectionError_ as error:
410
- sys.exit(f"prepare-connector: {error}")
411
- print(f" connection: {found.get('Name')} ({args.connection_id})")
412
-
413
- # Record the connection in bindings.json on BOTH routes. Until 3.30.5 only
414
- # discovery wrote it: an explicit `--connection-id` — the one way past two
415
- # connections that share a name — left the author hand-writing the id and
416
- # folder key, and the hint below printed `<binding-name>` placeholders that
417
- # went straight into source and bindings.json (measured on the eval corpus:
418
- # angle-bracket and all-zero "GUIDs" in 16 archived bindings.json files).
419
- if not args.no_bindings:
420
- # Taken before the first write so a later refusal can undo exactly this
421
- # command's changes, and only them.
422
- _snapshot_bindings(args)
423
- symbolic = args.bind_connection or args.key.rsplit("-", 1)[-1]
424
- try:
425
- wrote_conn, wrote_folder = _connections.merge_bindings(
426
- args.bindings, found, symbolic, args.bind_folder)
427
- except _connections.ConnectionError_ as error:
428
- sys.exit(f"prepare-connector: {error}")
429
- args._bound_connection = symbolic
430
- args._bound_folder = args.bind_folder
431
- for name, changed in ((symbolic, wrote_conn),
432
- (args.bind_folder, wrote_folder)):
433
- if changed:
434
- print(f" bindings: {name} -> {args.bindings}")
435
-
436
- gen.set_connection_id(args.connection_id)
437
- gen.set_parent_fields(parent_fields)
438
- gen.reset_failed_field_describes()
439
- node_type = f"uipath.connector.{args.key}.{args.action}"
440
-
441
- with tempfile.TemporaryDirectory() as tmp:
442
- cache = os.path.join(tmp, "reg")
443
- is_cache = os.path.join(tmp, "is")
444
- os.makedirs(cache)
445
- os.makedirs(is_cache)
446
-
447
- path = gen.registry_get(node_type, cache, quiet=True)
448
- if not path:
449
- # Not an activity. Connector EVENTS live one namespace over —
450
- # `uipath.connector.trigger.<key>.<event>` / `.event.<key>.<event>`
451
- # — and their registry definition only answers with a connection,
452
- # which this command has by now. Same loop, different twin pair.
453
- if _prepare_event(args, tmp, cache):
454
- return
455
- sys.exit(_unresolved_action_message(args.key, args.action, node_type))
456
- # The registry's own declaration of parent inputs, read while the file
457
- # still exists: `path` lives in this temporary directory.
458
- declared_parents = _declared_parents(path)
459
- entry = gen.extract_canonical(path)
460
- if not entry:
461
- sys.exit(f"prepare-connector: no canonical entry for {node_type} (unexpected registry shape).")
462
- if args.version:
463
- entry["version"] = args.version
464
- if parent_fields:
465
- request = _object_action_request(path, parent_fields)
466
- if not request:
467
- expected = [
468
- ", ".join(fields)
469
- for _name, fields in _object_action_specs(path)
470
- ]
471
- expected_text = " or ".join(expected) or "<none advertised>"
472
- sys.exit(
473
- "prepare-connector: parent-field names must match one "
474
- "api-type ObjectAction exactly. Given: "
475
- f"{', '.join(parent_fields)}. Expected: {expected_text}."
476
- )
477
- entry["operation"]["customFieldsRequestDetails"] = request
478
-
479
- is_expandable_generic = (
480
- entry["operation"].get("activityType") == "Generic"
481
- and not gen._is_http_passthrough(entry)
482
- )
483
- if is_expandable_generic:
484
- if not args.object and not args.all_objects:
485
- sys.exit(
486
- "prepare-connector: this is a Generic action; pass "
487
- "--object <name> to prepare one object or --all-objects "
488
- "to prepare the full connected catalog."
489
- )
490
- templates = gen._generic_templates_by_op([entry]).get(args.key, {})
491
- if args.object:
492
- # The author's name, not necessarily the API's: `Company__C`
493
- # from a prompt is `Company__c` in the catalog, and equality
494
- # used to dead-end on exactly that. Match it deterministically
495
- # BEFORE expansion — expansion re-reads the same file-cached
496
- # catalog, so the match costs no extra network.
497
- verb = next(iter(templates)) if len(templates) == 1 else None
498
- requested = args.object
499
- matched_row = _match_catalog_object(
500
- args.key, args.object, verb, is_cache
501
- )
502
- if matched_row is not None:
503
- args.object = matched_row.get("Name") or args.object
504
- args._object_requested = requested
505
- args._object_row = matched_row
506
- args._object_verb = verb
507
- selected = None if args.all_objects else {args.object}
508
- prepared_entries = gen.expand_generic_for_connector(
509
- args.key, templates, is_cache, object_names=selected
510
- )
511
- if not prepared_entries:
512
- operation = entry["operation"].get("name") or args.action
513
- sys.exit(_missing_generic_schema_message(
514
- operation,
515
- None if args.all_objects else args.object,
516
- ))
517
- else:
518
- if args.object or args.all_objects:
519
- sys.exit(
520
- "prepare-connector: --object/--all-objects apply only to "
521
- "Generic connector actions."
522
- )
523
- gen.enrich_entry(entry, is_cache) # full schema via --connection-id
524
- prepared_entries = [entry]
525
-
526
- tmplib = os.path.join(tmp, "lib")
527
- gen.write_library(tmplib, prepared_entries, {entry["nodeType"]: path})
528
- kept_schema = _keep_prepared_schema(
529
- args, entry["nodeType"], tmplib, parent_fields,
530
- )
531
- if kept_schema is None:
532
- merge_into(tmplib, args.out)
533
-
534
- # The object decision is the FIRST resolution — recorded before any
535
- # lookup rows, because it is their prerequisite: the schema the lookups
536
- # were generated from is the one this match selected.
537
- if getattr(args, "_object_row", None) is not None:
538
- try:
539
- ledger_path = _objects.record_object_resolution(
540
- args.out, node_type, args._object_requested,
541
- args._object_row, args._object_verb,
542
- )
543
- except _objects.LedgerError as error:
544
- sys.exit(f"prepare-connector: {error}")
545
- print(f" object: {args._object_requested} -> {args.object} "
546
- f"recorded in {ledger_path}")
547
-
548
- if args.resolve:
549
- _write_resolutions(args, node_type)
550
-
551
- _regenerate_descriptors(args)
552
- n_fields = _count_input_fields(args.out, entry["nodeType"], args.object)
553
- n_variants = len(prepared_entries)
554
- ts_note = "JSON overlay only" if args.json_only else f"overlay + {args.key}.ts"
555
- field_note = _input_field_note(n_fields)
556
- # The describe is the evidence, not the finished field count. A rejected
557
- # parent set leaves `enrich_entry` returning the registry-DECLARED fields,
558
- # so the overlay looks plausible (measured: project-only on Jira
559
- # create-issue writes 3 declared fields) while the real answer was a
560
- # Failure. Surface the CLI's own message instead of that quiet success.
561
- failures = gen.failed_field_describes()
562
- if args.field and (failures or n_fields == 0):
563
- sys.exit(_rejected_parent_fields_message(args.field, failures))
564
- print(
565
- f"prepare-connector: {node_type} → {args.out}/ "
566
- f"({ts_note}, {n_variants} variant(s), {field_note})"
567
- )
568
- if kept_schema is not None:
569
- kept, discarded = kept_schema
570
- print(
571
- f"prepare-connector: kept the prepared schema for {args.action} "
572
- f"({kept} input field(s)) — this run passed no -f, so its schema "
573
- f"action did not run and the unresolved entry ({discarded} field(s)) "
574
- f"would have replaced it. The resolutions above are recorded either "
575
- f"way; re-run with -f to refresh the schema itself."
576
- )
577
- _print_lookup_vocabulary(args, node_type)
578
- # Whether this operation has parent fields is answered by what we just
579
- # prepared, not by a catalog flag: `extract_canonical` does not run the bulk
580
- # discovery-marking pass, so `requiresConnectionForSchema` is still False
581
- # here. A reference path carrying a {placeholder} IS the declaration.
582
- parents, parent_only = _parent_fields(
583
- args.out, entry["nodeType"], args.object
584
- )
585
- if parent_only and args.field:
586
- # The set was SHAPED right (nothing was rejected) but resolved to
587
- # nothing new, so at least one value does not exist on this connection.
588
- sys.exit(
589
- "prepare-connector: the parent-field values did not resolve — the "
590
- "prepared schema is still parent-only (" + ", ".join(parents) +
591
- f"). Given: {', '.join(args.field)}. The names were accepted, so a "
592
- "VALUE is wrong for this connection: resolve each one against this "
593
- "connection (ids are parent-scoped — a Jira issue-type id is valid "
594
- "only inside its project) rather than reusing one from another "
595
- "project, connection, or task."
596
- )
597
- # stdout is block-buffered under a pipe; flush so a `2>&1` reader sees the
598
- # note after the overlay line it refers to, not before it.
599
- sys.stdout.flush()
600
- # Whether the schema action RAN is decided by its inputs, not by its
601
- # output: every parent it names must have arrived as `-f`. Both earlier
602
- # tests read the result instead, and between them let Jira's `create-issue`
603
- # through silently — parent-only PLUS its own `key`, so "every field is a
604
- # parent" was false and the registry declares no api-type ObjectAction for
605
- # it either. The overlay then carried 3 of 19 inputs, `prepare` printed a
606
- # clean "next:", and the author found out when `compile` refused `summary`.
607
- # Union of both declaration sites so an overlay that omits `schemaAction`
608
- # still gets the registry's answer.
609
- action_parents = _schema_action_parents(args.out, entry["nodeType"], args.object)
610
- action_parents += [
611
- name for name in declared_parents if name not in action_parents
612
- ]
613
- missing = [name for name in action_parents if name not in parent_fields]
614
- # Silent when the schema was KEPT: the note's premise is that the overlay
615
- # now carries only the unresolved entry, and it does not — the richer one
616
- # survived. Telling an author to "materialize the full field set" they are
617
- # already holding sends them round a loop that changes nothing.
618
- if missing and kept_schema is None:
619
- # A schema parent IS a lookup: the id `--resolve` just recorded is the
620
- # very value `-f` wants. The generic "resolve X and re-run" note made
621
- # an agent run this prepare twice and hand-copy the id between the two
622
- # commands (observed: 10732 retyped from the first run's output into
623
- # the second run's -f). When every parent already has a recorded
624
- # resolution, print the re-run ready to paste instead.
625
- resolved_parents = _resolved_parent_values(args.out, node_type, action_parents)
626
- given = " ".join(
627
- f"-f {name}={parent_fields[name]}"
628
- for name in action_parents
629
- if name in parent_fields
630
- )
631
- if set(resolved_parents) == set(action_parents):
632
- fields_arg = " ".join(
633
- f"-f {name}={resolved_parents[name]}" for name in action_parents
634
- )
635
- print(
636
- "prepare-connector: note: this operation resolves its fields "
637
- "from the VALUES of parent inputs, and the resolutions above "
638
- "ARE those values. Re-run to materialize the full field set:\n"
639
- f" npx flow-sdk registry prepare {args.key} {args.action} "
640
- f"{fields_arg}",
641
- file=sys.stderr,
642
- )
643
- else:
644
- example = " ".join(
645
- f"-f {name}={parent_fields.get(name, '<value>')}"
646
- for name in action_parents
647
- )
648
- print(
649
- "prepare-connector: note: this operation resolves its fields "
650
- "from the VALUES of " + ", ".join(action_parents) +
651
- " (its schema action), and " +
652
- ("none were given" if len(missing) == len(action_parents)
653
- else f"{', '.join(missing)} " +
654
- ("was" if len(missing) == 1 else "were") + " not" +
655
- (f" (given: {given})" if given else "")) +
656
- ", so the overlay above carries only the unresolved schema — a "
657
- "body field the object declares is not there yet and compile "
658
- "will refuse it as an unknown input. Re-run passing ALL of them "
659
- "as -f NAME=VALUE (a partial set is rejected):\n"
660
- f" npx flow-sdk registry prepare {args.key} {args.action} {example}",
661
- file=sys.stderr,
662
- )
663
- if not args.json_only:
664
- # The hint printed the placeholders `<binding-name>` /
665
- # `<folder-binding-name>` even when this run had just WRITTEN both
666
- # bindings and knew their names. Print what it bound, so the last line
667
- # of output is a call the reader can paste.
668
- usage = _descriptor_usage(
669
- args.out, args.key,
670
- getattr(args, "_bound_connection", None) or "<binding-name>",
671
- getattr(args, "_bound_folder", None) or "<folder-binding-name>",
672
- )
673
- repointed = _repoint_source(args, args.key)
674
- if repointed and repointed[0].startswith("repointed "):
675
- # The edit is DONE, so telling the author to make it is now false.
676
- # Drop the import line and keep the rest, which still teaches the
677
- # call shape and the resolved-identity rule.
678
- usage = [line for line in usage if not line.startswith("import {")]
679
- for line in repointed:
680
- print(f"prepare-connector: {line}")
681
- for line in usage:
682
- print(f"prepare-connector: next: {line}")
683
-
684
-
685
- def _print_lookup_vocabulary(args, node_type: str) -> None:
686
- """Print the lookup table for the operation just prepared.
687
-
688
- The markdown catalog renders this table for BAKED schemas; a schema that
689
- only exists because this command ran has no page to carry it, so the
690
- command itself is where the vocabulary first becomes knowable. Without it
691
- the author meets a custom object's lookup fields one `check` warning at a
692
- time instead of all at once, here, beside the import line.
693
- """
694
- specs = _lookup_specs_from_overlay(args.out, node_type, args.object)
695
- if not specs:
696
- return
697
- where = f" ({args.object})" if args.object else ""
698
- print(f"prepare-connector: lookup fields on {args.action}{where} — "
699
- "these hold ids; author lookup(...) and resolve, don't paste one:")
700
- shown = list(specs.items())[:12]
701
- for field, spec in shown:
702
- by = " | ".join(spec.get("by") or []) or "?"
703
- target = f"{spec.get('objectName') or spec.get('path') or '?'}.{spec.get('value') or 'id'}"
704
- print(f" {field} — matches on {by}, sends {target}")
705
- if len(specs) > len(shown):
706
- print(f" … and {len(specs) - len(shown)} more")
707
- field, spec = shown[0]
708
- by = (spec.get("by") or ["id"])[0]
709
- obj = f" --object {args.object}" if args.object else ""
710
- print("prepare-connector: resolve with: "
711
- f"npx flow-sdk registry prepare {args.key} {args.action}{obj} "
712
- f"--resolve {field}:{by}=<value>")
713
-
714
-
715
- def _resolved_parent_values(out_dir: str, node_type: str, parents) -> dict:
716
- """`{parent: resolved id}` for every parent with a recorded resolution.
717
-
718
- Read from the ledger just written, so the paste-ready `-f` re-run carries
719
- the ids THIS invocation resolved — never guessed, never retyped.
720
- """
721
- try:
722
- ledger = _objects.read_ledger(out_dir)
723
- except _objects.LedgerError:
724
- return {}
725
- wanted = set(parents)
726
- out: dict = {}
727
- for key, row in (ledger.get("resolutions") or {}).items():
728
- try:
729
- key_node, field, _by, _match = json.loads(key)
730
- except (ValueError, TypeError):
731
- continue
732
- if key_node == node_type and field in wanted:
733
- out[field] = row.get("value")
734
- return {k: v for k, v in out.items() if v not in (None, "")}
735
-
736
-
737
- def _match_catalog_object(key: str, wanted: str, verb: str | None, is_cache: str):
738
- """The catalog row `--object` names, matched by `_objects.match_object`.
739
-
740
- Returns the row, or None when the catalog itself is unavailable — that
741
- failure belongs to expansion, whose message already covers it. Every
742
- refusal past the name rungs (zero matches, substring — unique or
743
- ambiguous) is retried once against a refreshed catalog before it is
744
- believed: the `uip is resources list` cache never invalidates itself, so
745
- an object the tenant gained after the cache was written reads as
746
- nonexistent (measured: 18 objects stale vs 864 fresh on
747
- uipath-salesforce-sfdc). The one exception — which refusal is retriable is
748
- the exception's call, not this loop's — is ambiguity WITHIN a name rung:
749
- two case-variant names are a fact a refresh will not change.
750
- """
751
- rows = gen.is_resources_list(key, is_cache, operation=verb) or []
752
- if not rows:
753
- return None
754
- for attempt in ("cached", "refreshed"):
755
- try:
756
- row, note = _objects.match_object(rows, wanted, verb)
757
- except _objects.ObjectMatchError as error:
758
- if attempt == "cached" and error.retriable:
759
- rows = gen.is_resources_list(
760
- key, is_cache, operation=verb, refresh=True
761
- ) or rows
762
- continue
763
- sys.exit(f"prepare-connector: {error}")
764
- if note:
765
- print(f" object: {note}")
766
- return row
767
- return None
768
-
769
-
770
- def _refuse_unless_byoa_connection(args) -> None:
771
- """Refuse a standard connection on an event object that requires BYOA.
772
-
773
- V1 measured the failure this prevents: the trigger deploys and then faults
774
- at runtime with a webhook error naming nothing about connections. The
775
- requirement is knowable now, so it is refused now, with the connections
776
- that WOULD work.
777
-
778
- A refusal must leave nothing behind. Stage 0 has already written this
779
- connection into `bindings.json` by the time the event's own metadata says
780
- it is the wrong KIND of connection — the BYOA fact needs a connection to
781
- discover — so the file is restored to exactly what it was before the
782
- refusal is raised. Otherwise the overlay keeps pointing at a connection
783
- this command just forbade.
784
- """
785
- try:
786
- rows = _connections.byoa_connections(args.key)
787
- except _connections.ConnectionError_ as error:
788
- # A listing that FAILED is not a tenant with no BYOA connections; the
789
- # remedies are opposite and confusing them sends the reader to create
790
- # a connection they may already have.
791
- _restore_bindings(args)
792
- sys.exit(
793
- f"prepare-connector: this event object requires a BYOA "
794
- f"(bring-your-own-app) connection, and the BYOA listing needed to "
795
- f"check {args.connection_id} against it failed: {error}"
796
- )
797
- if args.connection_id in {str(row.get("Id")) for row in rows}:
798
- return
799
- _restore_bindings(args)
800
- sys.exit(
801
- f"prepare-connector: this event object requires a BYOA "
802
- f"(bring-your-own-app) connection, and {args.connection_id} is not "
803
- f"one. A standard connection deploys and then faults at runtime with "
804
- f"an unrelated-looking webhook error. BYOA {args.key} connections:\n"
805
- f"{_connections.byoa_listing(rows, args.key)}"
806
- )
807
-
808
-
809
- def _event_operation(operation: dict) -> str:
810
- """The operation key every trigger API takes.
811
-
812
- NOT `operation.name`: that is the object's CRUD verb (`Create` on outlook
813
- `email-received`), while the event's own operation is `EMAIL_RECEIVED`,
814
- which `extract_canonical` stamps as `eventOperation` from the manifest's
815
- `model.context`. `name` happens to fall back to the same context value
816
- when the manifest omits `connectorMethodInfo.operation`, so reading it
817
- works or silently queries the WRONG operation depending on which fallback
818
- rung a given connector's manifest exercises — and both generation passes
819
- already key on `eventOperation`.
820
- """
821
- return str(operation.get("eventOperation") or operation.get("name") or "")
822
-
823
-
824
- def _snapshot_bindings(args) -> None:
825
- """Remember `bindings.json` byte-for-byte, before stage 0 touches it."""
826
- path = getattr(args, "bindings", None)
827
- if not path:
828
- return
829
- try:
830
- with open(path, "rb") as handle:
831
- args._bindings_before = handle.read()
832
- except OSError:
833
- args._bindings_before = None # the file did not exist
834
-
835
-
836
- def _restore_bindings(args) -> None:
837
- """Put `bindings.json` back exactly as `_snapshot_bindings` found it."""
838
- path = getattr(args, "bindings", None)
839
- if not path or not hasattr(args, "_bindings_before"):
840
- return
841
- before = args._bindings_before
842
- try:
843
- if before is None:
844
- if os.path.exists(path):
845
- os.remove(path)
846
- else:
847
- with open(path, "wb") as handle:
848
- handle.write(before)
849
- except OSError as error:
850
- print(f" WARN: could not restore {path} after refusing the "
851
- f"connection: {error}", file=sys.stderr)
852
-
853
-
854
- def _prepare_event(args, tmp: str, cache: str) -> bool:
855
- """Prepare a connector EVENT — the trigger/wait twin of an activity.
856
-
857
- Returns False when `<key> <action>` is not an event either, so the caller
858
- can raise the ordinary unknown-action error. Everything else is handled
859
- here: the overlay entry is stored under the WAIT form
860
- (`uipath.connector.event.<key>.<action>`) because the registry only
861
- enriches the START form and the library derives the start form from its
862
- wait sibling (`deriveTriggerForm`) — one stored variant serves both node
863
- roles.
864
- """
865
- trigger_nt = f"uipath.connector.trigger.{args.key}.{args.action}"
866
- event_nt = f"uipath.connector.event.{args.key}.{args.action}"
867
- path = gen.registry_get(trigger_nt, cache)
868
- if not path:
869
- return False
870
- entry = gen.extract_canonical(path)
871
- if not entry:
872
- return False
873
- if args.field:
874
- sys.exit("prepare-connector: -f parent fields apply to activities; "
875
- "a connector event has none.")
876
- if args.all_objects:
877
- sys.exit("prepare-connector: --all-objects applies to Generic "
878
- "activities; prepare a generic EVENT for one --object.")
879
- with open(path, encoding="utf-8") as handle:
880
- raw = json.load(handle)
881
- node = (raw.get("Data") or {}).get("Node") or raw.get("Node") or {}
882
- op = entry["operation"]
883
- operation = _event_operation(op)
884
- generic = op.get("activityType") == "GenericTrigger"
885
-
886
- # The objects listing is the generic catalog AND the per-object fact sheet
887
- # (BYOA, webhook visibility, bucket-scoped Parameters). For a curated
888
- # event it is advisory — the definition already carries the sections — so
889
- # a listing failure degrades to a warning there and is fatal only where
890
- # it is the only source of truth.
891
- try:
892
- rows = _events.triggers_objects(args.key, operation, args.connection_id)
893
- except _events.EventDiscoveryError as error:
894
- if generic:
895
- sys.exit(f"prepare-connector: {error}")
896
- rows = []
897
- print(f" WARN: {error}", file=sys.stderr)
898
-
899
- describe = None
900
- object_row = None
901
- requested_object = None
902
- if generic:
903
- if not args.object:
904
- sample = ", ".join(
905
- str(row.get("Name")) for row in rows[:8] if row.get("Name"))
906
- sys.exit(
907
- f"prepare-connector: {args.action} is a Generic event — it "
908
- f"fires for ONE object of the connection. Pass --object "
909
- f"<name>. This connection's objects include: {sample}"
910
- + (" …" if len(rows) > 8 else "")
911
- )
912
- # The author's name, not necessarily the API's — same ladder, same
913
- # refusal semantics as a Generic activity. The listing is live (no
914
- # CLI-side cache), so there is nothing to refresh on a miss.
915
- try:
916
- object_row, note = _objects.match_object(rows, args.object, operation)
917
- except _objects.ObjectMatchError as error:
918
- sys.exit(f"prepare-connector: {error}")
919
- if note:
920
- print(f" object: {note}")
921
- requested_object = args.object
922
- args.object = object_row.get("Name") or args.object
923
- try:
924
- describe = _events.triggers_describe(
925
- args.key, operation, args.object, args.connection_id)
926
- except _events.EventDiscoveryError as error:
927
- sys.exit(f"prepare-connector: {error}")
928
- op["objectName"] = args.object
929
- op["objectDisplayName"] = str(object_row.get("DisplayName") or "")
930
- else:
931
- if args.object:
932
- sys.exit(
933
- f"prepare-connector: --object applies to Generic events; "
934
- f"{args.action}'s object is built in "
935
- f"({op.get('objectName') or 'none'})."
936
- )
937
- baked = str(op.get("objectName") or "")
938
- object_row = next(
939
- (row for row in rows if str(row.get("Name")) == baked), None)
940
- # `triggers describe` is the AUTHORITY for a curated event too, not
941
- # just a generic one. The registry definition mirrors it and the
942
- # mirror is lossy: measured on outlook `email-received`, describe
943
- # returns 15 filter fields and the registry 14 — `isRead` is only in
944
- # describe, and both agree on all 73 output fields. Reading the mirror
945
- # would leave `check` REFUSING a filter on `isRead` as an unknown
946
- # field, which is the one diagnostic here that is an error. Fall back
947
- # to the definition's own sections when describe cannot answer (a
948
- # baked object name the trigger catalog does not list) rather than
949
- # failing the prepare — the mirror is still a usable vocabulary.
950
- if baked:
951
- try:
952
- describe = _events.triggers_describe(
953
- args.key, operation, baked, args.connection_id)
954
- except _events.EventDiscoveryError as error:
955
- print(f" WARN: {error}\n"
956
- f" falling back to the registry definition's own "
957
- f"schema sections for {baked}", file=sys.stderr)
958
-
959
- facts = _events.event_facts(node, object_row)
960
- if facts["byoaConnection"]:
961
- _refuse_unless_byoa_connection(args)
962
-
963
- # One stored shape: the registry definition with the describe sections
964
- # made true for this object (generic), or with the object row's
965
- # Parameters unioned in (curated). `Library.eventParameterFields` reads
966
- # `v1def.eventParameters.fields`, so the overlay v1def IS the store.
967
- object_parameters = (object_row or {}).get("Parameters")
968
- if describe is not None:
969
- node = _events.merge_describe_into_node(node, describe, object_parameters)
970
- else:
971
- node = dict(node)
972
- node["eventParameters"] = {"fields": _events.union_event_parameters(
973
- (node.get("eventParameters") or {}).get("fields"),
974
- object_parameters,
975
- )}
976
- if (node.get("outputResponseDefinition") or {}).get("fields"):
977
- entry["outputSchema"] = {
978
- "fields": node["outputResponseDefinition"]["fields"]}
979
- node["nodeType"] = event_nt
980
- entry["nodeType"] = event_nt
981
-
982
- merged_path = os.path.join(tmp, "event-def.json")
983
- with open(merged_path, "w", encoding="utf-8") as handle:
984
- json.dump({"Data": {"Node": node}}, handle)
985
-
986
- tmplib = os.path.join(tmp, "eventlib")
987
- gen.write_library(tmplib, [entry], {event_nt: merged_path})
988
- merge_into(tmplib, args.out)
989
-
990
- if generic:
991
- try:
992
- ledger_path = _objects.record_object_resolution(
993
- args.out, event_nt, requested_object, object_row, operation)
994
- except _objects.LedgerError as error:
995
- sys.exit(f"prepare-connector: {error}")
996
- print(f" object: {requested_object} -> {args.object} "
997
- f"recorded in {ledger_path}")
998
-
999
- if args.resolve:
1000
- _write_resolutions(args, event_nt)
1001
-
1002
- _regenerate_descriptors(args)
1003
-
1004
- where_fields = (node.get("eventParameters") or {}).get("fields") or []
1005
- filter_fields = (node.get("filterFields") or {}).get("fields") or []
1006
- mode = facts["eventMode"]
1007
- print(f"prepare-connector: {event_nt} → {args.out}/ (event overlay, "
1008
- f"{operation}, {mode}, {len(where_fields)} where-parameter(s), "
1009
- f"{len(filter_fields)} filter field(s))")
1010
- sys.stdout.flush()
1011
- _print_lookup_vocabulary(args, event_nt)
1012
- if filter_fields:
1013
- names = ", ".join(str(f.get("name")) for f in filter_fields[:8])
1014
- print(f"prepare-connector: filterable fields ({len(filter_fields)}): "
1015
- f"{names}" + (" …" if len(filter_fields) > 8 else ""))
1016
- if mode == "webhooks":
1017
- # Registration is an act on the external service — the one step this
1018
- # loop cannot make deterministic. Hand the author the connector's own
1019
- # words instead of leaving `uip is webhooks config` to be discovered.
1020
- print("prepare-connector: note: this event is WEBHOOK mode — it "
1021
- "cannot be debugged locally (deploy to test), and the webhook "
1022
- "URL must be registered on the external service"
1023
- + (":" if facts["textBlocks"] else
1024
- " (retrieve it with `uip is webhooks config`)."),
1025
- file=sys.stderr)
1026
- for block in facts["textBlocks"]:
1027
- print(f" {block}", file=sys.stderr)
1028
- connection_name = getattr(args, "_bound_connection", None) or "<connection>"
1029
- folder_name = getattr(args, "_bound_folder", None) or "shared"
1030
- subscription = (
1031
- f"{{ connector: '{args.key}', event: '{args.action}'"
1032
- + (f", object: '{args.object}'" if generic else "")
1033
- + f", connection: '{connection_name}', folder: '{folder_name}' }}"
1034
- )
1035
- print(f"prepare-connector: next: .trigger(onEvent({subscription})) to "
1036
- f"start the flow on this event — or .step('…', "
1037
- f"waitForEvent({subscription})) to pause mid-flow for it")
1038
- return True
1039
-
1040
-
1041
- def _overlay_has_generic_expanded(out: str) -> bool:
1042
- """Does the overlay hold ANY `GenericExpanded` entry, from any prepare?"""
1043
- try:
1044
- with open(os.path.join(out, "index.json"), encoding="utf-8") as handle:
1045
- entries = json.load(handle).get("entries") or []
1046
- except (OSError, ValueError):
1047
- return False
1048
- return any(e.get("activityType") == "GenericExpanded" for e in entries)
1049
-
1050
-
1051
- def _regenerate_descriptors(args) -> None:
1052
- """Rewrite the overlay's typed descriptors from the overlay's own JSON.
1053
-
1054
- Both prepare paths end here. The EVENT path used not to: it wrote the JSON
1055
- overlay and returned, so a project whose only prepare was an event got no
1056
- `.ts` at all, and one that had prepared an action earlier kept that older
1057
- run's module — generated before the event's parameters were in the overlay,
1058
- so `where` was `Record<string, string>` and the trigger carried no
1059
- `lookups`. That is the module `prepare` tells the author to import, and
1060
- `--refresh-lookups` could not repair it, because nothing on the event path
1061
- regenerated TypeScript at all.
1062
-
1063
- `--include-generic-expanded` is decided from the WHOLE overlay index rather
1064
- than the entries of this run: the generator rewrites every module under
1065
- `--output`, so reading one run would drop the descriptors an earlier
1066
- `GenericExpanded` prepare had written.
1067
- """
1068
- if args.json_only:
1069
- return
1070
- command = [
1071
- sys.executable, str(HERE / "generate_connectors_ts.py"),
1072
- "--library", args.out, "--output", args.out, "--import", args.imp,
1073
- ]
1074
- if _overlay_has_generic_expanded(args.out):
1075
- command.append("--include-generic-expanded")
1076
- subprocess.run(command, check=True)
1077
-
1078
-
1079
- def _write_resolutions(args, node_type: str) -> None:
1080
- """Resolve every `--resolve` and merge the answers into the overlay.
1081
-
1082
- A SEPARATE FILE from the schema overlay, deliberately. A prepared schema is
1083
- a fact about the connector; a resolution is a fact about one tenant's data,
1084
- so it ages differently and most consumers will want to gitignore it. Keeping
1085
- them apart is also what lets `--refresh-lookups` mean something later.
1086
-
1087
- Existing entries STAND rather than being re-resolved. A resolved id is a
1088
- snapshot either way; re-resolving on every call would make an unrelated
1089
- `prepare` silently change an artifact, which is the opposite of what the
1090
- file is for. Re-resolving is a deliberate act with a visible diff.
1091
- """
1092
- specs = _lookup_specs_from_overlay(args.out, node_type, args.object)
1093
- requested = [_resolve.parse_resolve_flag(raw) for raw in args.resolve]
1094
-
1095
- unknown = [f for f, _b, _v in requested if f not in specs]
1096
- if unknown:
1097
- known = ", ".join(sorted(specs)) or "(none)"
1098
- sys.exit(
1099
- f"prepare-connector: no lookup is declared for "
1100
- f"{', '.join(repr(f) for f in unknown)} on {node_type}. "
1101
- f"Fields with a lookup: {known}."
1102
- )
1103
-
1104
- ordered = _resolve.order_resolutions(requested, specs)
1105
- path = os.path.join(args.out, "resolutions.json")
1106
- # Read and rewrite the WHOLE ledger, not just `resolutions`: the file also
1107
- # carries the `objects` section (the object decision recorded above), and
1108
- # rewriting only one section used to silently drop the other. A file that
1109
- # will not parse is REFUSED rather than reset — same rule as bindings.json.
1110
- try:
1111
- ledger = _objects.read_ledger(args.out)
1112
- except _objects.LedgerError as error:
1113
- sys.exit(f"prepare-connector: {error}")
1114
- existing = ledger.get("resolutions") or {}
1115
-
1116
- resolved_values: dict[str, str] = {}
1117
- for field, by, value in ordered:
1118
- # COMPACT SEPARATORS, to match JavaScript exactly. `compile` computes
1119
- # this key with `JSON.stringify`, which emits no space after a comma;
1120
- # Python's default emits `", "`. The two encodings of the same tuple are
1121
- # different strings, so every resolution `prepare` wrote was invisible to
1122
- # the compile that had just asked for it — the command did the work,
1123
- # recorded it, and then refused to use it.
1124
- key = json.dumps([node_type, field, by, value], separators=(",", ":"))
1125
- if key in existing and not args.refresh_lookups:
1126
- resolved_values[field] = existing[key]["value"]
1127
- print(f" {field}: {by}={value} -> {existing[key]['value']} (already resolved)")
1128
- continue
1129
- try:
1130
- got = _resolve.resolve_one(
1131
- args.key, specs[field], field, by, value,
1132
- args.connection_id, resolved_values,
1133
- )
1134
- except _resolve.LookupExhausted as error:
1135
- # The collection was read to the END on this connection and the
1136
- # value is not in it. That is evidence about the CONNECTION, not
1137
- # only about the value — so before failing, try the other enabled
1138
- # connections for this connector. A transport failure is the
1139
- # sibling exception below and is never retried: retrying it would
1140
- # dress a broken call up as "no connection has it".
1141
- _retry_or_exit(args, node_type, specs, ordered, error)
1142
- except _resolve.ResolutionError as error:
1143
- sys.exit(f"prepare-connector: {error}")
1144
- resolved_values[field] = got
1145
- existing[key] = {
1146
- "nodeType": node_type, "field": field, "by": by,
1147
- "match": value, "value": got,
1148
- }
1149
- print(f" {field}: {by}={value} -> {got}")
1150
-
1151
- os.makedirs(args.out, exist_ok=True)
1152
- ledger["schemaVersion"] = "1"
1153
- ledger["resolutions"] = existing
1154
- with open(path, "w", encoding="utf-8") as handle:
1155
- json.dump(ledger, handle, indent=2, sort_keys=True)
1156
- handle.write("\n")
1157
- print(f" wrote {len(existing)} resolution(s) to {path}")
1158
-
1159
-
1160
- def _candidate_label(row: dict) -> str:
1161
- """`name (folder)` for a per-candidate report line."""
1162
- return f"{row.get('Name')} ({row.get('Folder')})"
1163
-
1164
-
1165
- def _probe_candidate(args, specs, ordered, connection_id: str):
1166
- """Resolve every requested lookup against one connection.
1167
-
1168
- Returns `None` when all of them resolve, or the first reason they did not:
1169
- `("exhausted", <LookupExhausted>)` or `("error", <ResolutionError>)`. The
1170
- two are kept apart all the way up, because only the first is evidence about
1171
- the connection.
1172
- """
1173
- resolved: dict[str, str] = {}
1174
- for field, by, value in ordered:
1175
- try:
1176
- resolved[field] = _resolve.resolve_one(
1177
- args.key, specs[field], field, by, value, connection_id, resolved,
1178
- )
1179
- except _resolve.LookupExhausted as error:
1180
- return ("exhausted", error)
1181
- except _resolve.ResolutionError as error:
1182
- return ("error", error)
1183
- return None
1184
-
1185
-
1186
- def _retry_or_exit(args, node_type: str, specs, ordered, first) -> None:
1187
- """Try the other candidate connections, or exit explaining what was tried.
1188
-
1189
- NEVER RETURNS. Either it re-execs this command pinned to a connection that
1190
- resolves everything, or it exits with a report.
1191
- """
1192
- if args._connection_pinned:
1193
- # An explicit --connection-id / --connection is the author's decision and
1194
- # is not overridden. The alternatives are still named, because the whole
1195
- # cost of this bug was not knowing they existed.
1196
- others = [
1197
- row for row in _connections.candidate_connections(args.key)
1198
- if str(row.get("Id")) != str(args.connection_id)
1199
- ]
1200
- extra = ""
1201
- if others:
1202
- extra = (
1203
- f"\n{len(others)} other enabled {args.key} connection"
1204
- f"{'s' if len(others) > 1 else ''} exist"
1205
- f"{'' if len(others) > 1 else 's'}:\n"
1206
- f"{_connections.candidate_listing(others)}\n"
1207
- "Re-run against one of those, or check the value. This run kept the "
1208
- "connection you named."
1209
- )
1210
- sys.exit(f"prepare-connector: {first}{extra}")
1211
-
1212
- try:
1213
- candidates = _connections.candidate_connections(args.key, args.connection)
1214
- except _connections.ConnectionError_ as error:
1215
- # The listing itself failed; report the original lookup failure, which is
1216
- # the thing the author asked about.
1217
- sys.exit(f"prepare-connector: {first}\n(could not list other connections: {error})")
1218
-
1219
- others = [row for row in candidates
1220
- if str(row.get("Id")) != str(args.connection_id)]
1221
- if not others:
1222
- sys.exit(
1223
- f"prepare-connector: {first}\n"
1224
- f"This is the only enabled {args.key} connection on the tenant, so the "
1225
- "value is what to check."
1226
- )
1227
-
1228
- print(f" {first.field}: not on this connection; trying "
1229
- f"{len(others)} other {args.key} connection(s)")
1230
- winners: list[dict] = []
1231
- report: list[str] = []
1232
- for row in others:
1233
- outcome = _probe_candidate(args, specs, ordered, str(row.get("Id")))
1234
- if outcome is None:
1235
- winners.append(row)
1236
- report.append(f" {_candidate_label(row)} resolves all "
1237
- f"{len(ordered)} lookup(s)")
1238
- continue
1239
- kind, error = outcome
1240
- if kind == "exhausted":
1241
- report.append(
1242
- f" {_candidate_label(row)} {error.field}: "
1243
- f"{error.scanned} record(s) scanned, no match")
1244
- else:
1245
- report.append(f" {_candidate_label(row)} {error}")
1246
-
1247
- if len(winners) == 1:
1248
- chosen = winners[0]
1249
- # Undo the bindings this run wrote for the connection that cannot serve
1250
- # the lookups, then start over pinned to the one that can. Re-running
1251
- # rather than switching in place is deliberate: the overlay and its
1252
- # lookup specs are described THROUGH a connection
1253
- # (`_lookup_specs_from_overlay`), so adopting a different connection
1254
- # after the describe would leave the overlay describing one connection
1255
- # while bindings.json names another.
1256
- _restore_bindings(args)
1257
- print(f" connection: {_candidate_label(chosen)} resolves "
1258
- f"{first.field}; re-running against it "
1259
- f"(--connection-id {chosen.get('Id')})")
1260
- argv = [sys.executable, *sys.argv,
1261
- "--connection-id", str(chosen.get("Id"))]
1262
- # The ledger keys a resolution by [nodeType, field, by, value] and NOT by
1263
- # connection, so an id recorded against the connection just abandoned
1264
- # would be reused as "already resolved". Force the re-run to resolve
1265
- # against the connection it actually pins.
1266
- if not args.refresh_lookups:
1267
- argv.append("--refresh-lookups")
1268
- sys.stdout.flush()
1269
- try:
1270
- os.execv(sys.executable, argv)
1271
- except OSError as error:
1272
- # `execv` does not return on success, so reaching here means the
1273
- # re-run could not start. Hand back the command rather than a
1274
- # traceback: the author is one paste from the answer.
1275
- pinned = " ".join(argv[2:])
1276
- sys.exit(
1277
- f"prepare-connector: {first}\n"
1278
- f"{_candidate_label(chosen)} resolves it, but this command could "
1279
- f"not re-run itself ({error}). Run:\n prepare {pinned}"
1280
- )
1281
- # Unreachable in production — execv replaced the process. Kept so the
1282
- # function can never fall through to the report below, which would
1283
- # contradict the line just printed.
1284
- raise SystemExit(0)
1285
-
1286
- if len(winners) > 1:
1287
- sys.exit(
1288
- f"prepare-connector: {len(winners)} {args.key} connections resolve "
1289
- f"every requested lookup, and they are not interchangeable — the ids "
1290
- f"they return differ per connection. Pick the one you mean:\n"
1291
- f"{_connections.candidate_listing(winners)}"
1292
- )
1293
-
1294
- # Nothing resolved anywhere. Report every candidate and what it held, the
1295
- # one connection first, so the reader can see the value really is absent
1296
- # rather than guess which connection was asked.
1297
- original = next(
1298
- (row for row in candidates
1299
- if str(row.get("Id")) == str(args.connection_id)),
1300
- {"Name": args.connection_id, "Folder": "?"},
1301
- )
1302
- lines = [f" {_candidate_label(original)} {first.field}: "
1303
- f"{first.scanned} record(s) scanned, no match", *report]
1304
- # HERE is where the collection listing finally belongs: every connection has
1305
- # been tried, so seeing what the collection actually holds is the next step
1306
- # rather than the detour it is while a retry is still available.
1307
- sys.exit(
1308
- f"prepare-connector: no {args.key} connection could resolve "
1309
- f"{first.field}: {first.by}={first.value!r}\n"
1310
- + "\n".join(lines) + "\n"
1311
- "Every candidate was read to the end, so the value is what to check now. "
1312
- f"To see what {first.collection!r} holds:\n"
1313
- f" uip is resources run list {args.key} {first.collection} "
1314
- f"--connection-id {args.connection_id}"
1315
- )
1316
-
1317
-
1318
- def _lookup_specs_from_overlay(
1319
- out_dir: str, node_type: str, object_name: str | None = None,
1320
- ) -> dict:
1321
- """The prepared operation's lookup specs, from the overlay just written.
1322
-
1323
- Read back from the overlay rather than recomputed, so a resolution is made
1324
- against the SAME schema the descriptors were generated from — including any
1325
- connection-resolved field that only exists because this `prepare` ran.
1326
- """
1327
- index_path = os.path.join(out_dir, "index.json")
1328
- if not os.path.exists(index_path):
1329
- return {}
1330
- with open(index_path, encoding="utf-8") as handle:
1331
- entries = json.load(handle).get("entries", [])
1332
- invariant = load_invariant_collections(out_dir)
1333
- for entry in entries:
1334
- if entry.get("nodeType") != node_type:
1335
- continue
1336
- # Generic object variants share a nodeType; without the object filter
1337
- # the FIRST variant answered for all of them, so a `--resolve` against
1338
- # the object just prepared read another object's lookup table.
1339
- if object_name and entry.get("objectName") not in ("", None, object_name):
1340
- continue
1341
- entry_path = os.path.join(out_dir, entry["path"])
1342
- v1_path = entry_path[:-5] + ".v1def.json"
1343
- if not os.path.exists(entry_path):
1344
- continue
1345
- with open(entry_path, encoding="utf-8") as handle:
1346
- canonical = json.load(handle)
1347
- v1def = {}
1348
- if os.path.exists(v1_path):
1349
- with open(v1_path, encoding="utf-8") as handle:
1350
- raw = json.load(handle)
1351
- v1def = raw[0] if isinstance(raw, list) else raw
1352
- # An EVENT's resolvable fields are its subscription parameters, and
1353
- # `v1def.eventParameters.fields` is the ONE authority for those — the
1354
- # same list the SDK, `check` and the descriptor generator read.
1355
- #
1356
- # This used to pick the source by testing whether `inputSchema` was
1357
- # empty, which was right only because the prepare path happens to
1358
- # leave `inputSchema` as the manifest wrote it while generation
1359
- # overwrites it. Either path changing flips the heuristic and the
1360
- # specs come from the manifest's message-BODY fields instead — the
1361
- # exact wrong list. The node kind is the fact being tested, so test
1362
- # it.
1363
- if node_type.startswith(("uipath.connector.event.",
1364
- "uipath.connector.trigger.")):
1365
- fields_source = {"inputSchema": {
1366
- "fields": (v1def.get("eventParameters") or {}).get("fields") or []
1367
- }}
1368
- else:
1369
- fields_source = canonical
1370
- specs = gen_ts.lookup_specs(fields_source, v1def, invariant)
1371
- # `filterPattern` is not on the descriptor spec (it is a retrieval
1372
- # detail, not an authoring one) but the resolver needs it.
1373
- catalog = {
1374
- f.get("name"): f["reference"]
1375
- for f in ((fields_source.get("inputSchema") or {}).get("fields") or [])
1376
- if isinstance(f.get("reference"), dict)
1377
- }
1378
- for field, spec in specs.items():
1379
- reference = catalog.get(field) or {}
1380
- if reference.get("filterPattern"):
1381
- spec["filterPattern"] = reference["filterPattern"]
1382
- return specs
1383
- return {}
1384
-
1385
-
1386
- # `import … from "<specifier>"` — the specifier is group 3, quotes preserved.
1387
- # Deliberately anchored to a real import STATEMENT: the instruction this
1388
- # replaces became `sed 's|./connectors/|./connectors-local/|'` in practice,
1389
- # which also rewrites the same text inside comments and inside the imports of
1390
- # connectors this run did NOT prepare (#704).
1391
- _IMPORT_RE = re.compile(
1392
- r"""(^\s*import\s[^;'"]*?\sfrom\s*)(['"])([^'"]+)(\2)""",
1393
- re.MULTILINE,
1394
- )
1395
-
1396
- # Where a `.flow.ts` will not be: generated output and installed packages.
1397
- _SOURCE_SKIP_DIRS = {"node_modules", "connectors", "connectors-local", ".git"}
1398
-
1399
-
1400
- def _resolves_to(specifier: str, source: Path, target: Path) -> bool:
1401
- """Does `specifier`, read from `source`, name the file `target`?
1402
-
1403
- Compared as RESOLVED paths, not as text, so `./connectors/x.ts` from the
1404
- project root and `../connectors/x.ts` from a nested source both match the
1405
- one file — and a same-named connector under a different root does not.
1406
- """
1407
- if not specifier.startswith("."):
1408
- return False # a bare package, never our overlay
1409
- try:
1410
- candidate = (source.parent / specifier).resolve()
1411
- except (OSError, ValueError):
1412
- return False
1413
- return candidate == target
1414
-
1415
-
1416
- def _sources_importing(baked: Path, root: Path) -> list[Path]:
1417
- """Every `*.flow.ts` under `root` whose import resolves to `baked`.
1418
-
1419
- Selection is by CONTENT rather than "the single flow in this directory":
1420
- a workspace with three flows, one of which uses this connector, has one
1421
- correct answer and this finds it.
1422
- """
1423
- found: list[Path] = []
1424
- for path in sorted(root.rglob("*.flow.ts")):
1425
- if _SOURCE_SKIP_DIRS & set(path.relative_to(root).parts[:-1]):
1426
- continue
1427
- try:
1428
- text = path.read_text(encoding="utf-8")
1429
- except (OSError, UnicodeDecodeError):
1430
- continue
1431
- if any(_resolves_to(m.group(3), path, baked) for m in _IMPORT_RE.finditer(text)):
1432
- found.append(path)
1433
- return found
1434
-
1435
-
1436
- def _repoint_import(source: Path, baked: Path, prepared: Path) -> str | None:
1437
- """Point `source`'s import at the prepared descriptor. The new specifier, or None.
1438
-
1439
- Only the quoted path changes. The rest of the statement — the named
1440
- bindings, an `as` alias, the quote style — is carried through untouched,
1441
- which is the whole difference between this and the find-and-replace it
1442
- replaces.
1443
- """
1444
- try:
1445
- text = source.read_text(encoding="utf-8")
1446
- except (OSError, UnicodeDecodeError):
1447
- return None
1448
- specifier = os.path.relpath(prepared, source.parent).replace(os.sep, "/")
1449
- if not specifier.startswith("."):
1450
- specifier = f"./{specifier}"
1451
- changed = False
1452
-
1453
- def swap(match: "re.Match[str]") -> str:
1454
- nonlocal changed
1455
- if not _resolves_to(match.group(3), source, baked):
1456
- return match.group(0)
1457
- changed = True
1458
- return f"{match.group(1)}{match.group(2)}{specifier}{match.group(4)}"
1459
-
1460
- rewritten = _IMPORT_RE.sub(swap, text)
1461
- if not changed or rewritten == text:
1462
- return None
1463
- source.write_text(rewritten, encoding="utf-8")
1464
- return specifier
1465
-
1466
-
1467
- def _repoint_source(args, connector_key: str) -> list[str]:
1468
- """Repoint the author's import, or explain why it was left alone.
1469
-
1470
- `prepare` already writes `bindings.json`, the overlay descriptor and
1471
- `resolutions.json`; the one thing it did NOT do was update the import
1472
- naming the descriptor it had just replaced. That left a mechanical edit —
1473
- one specifier, one file, exactly one right answer — to the caller, who has
1474
- strictly less information than this command does. Measured cost: an agent
1475
- reaches for `sed`, which repoints connectors that were never prepared and
1476
- breaks the flow with `Cannot find module`.
1477
-
1478
- Ambiguity keeps today's behaviour. Zero candidates or several, and nothing
1479
- is written — the printed import line still tells the author what to do, so
1480
- the fallback is exactly the status quo and never worse.
1481
- """
1482
- prepared = (Path(args.out) / f"{connector_key}.ts").resolve()
1483
- if getattr(args, "no_source_rewrite", False) or not prepared.is_file():
1484
- return []
1485
- baked = (Path.cwd() / "connectors" / f"{connector_key}.ts").resolve()
1486
-
1487
- if getattr(args, "source", None):
1488
- candidates = [Path(args.source).resolve()]
1489
- if not candidates[0].is_file():
1490
- return [f"--source {args.source} does not name a file; "
1491
- f"left the import alone"]
1492
- else:
1493
- candidates = _sources_importing(baked, Path.cwd())
1494
-
1495
- if not candidates:
1496
- return []
1497
- if len(candidates) > 1:
1498
- names = ", ".join(
1499
- c.relative_to(Path.cwd()).as_posix() for c in candidates)
1500
- return [f"{len(candidates)} sources import this connector ({names}); "
1501
- f"name one with --source to have it repointed"]
1502
-
1503
- source = candidates[0]
1504
- specifier = _repoint_import(source, baked, prepared)
1505
- if specifier is None:
1506
- return []
1507
- return [f"repointed {source.relative_to(Path.cwd()).as_posix()} "
1508
- f"-> {specifier}"]
1509
-
1510
-
1511
- def _descriptor_usage(out_dir: str, connector_key: str,
1512
- bound_connection: str = "<binding-name>",
1513
- bound_folder: str = "<folder-binding-name>") -> list[str]:
1514
- """Teach the author how to consume the connection-resolved descriptor.
1515
-
1516
- The live registry may use a different action id than the baked catalog. The
1517
- generated descriptor is the authority for that resolved identity, so the
1518
- success message must carry the author across that boundary instead of
1519
- leaving them to reverse-engineer index.json.
1520
- """
1521
- source = Path(out_dir) / f"{connector_key}.ts"
1522
- try:
1523
- generated = source.read_text()
1524
- exports = re.findall(
1525
- r"^export const ([A-Za-z_$][A-Za-z0-9_$]*) = "
1526
- r"descriptor<([A-Za-z_$][A-Za-z0-9_$]*),",
1527
- generated,
1528
- flags=re.MULTILINE,
1529
- )
1530
- except OSError:
1531
- generated = ""
1532
- exports = []
1533
-
1534
- import_path = os.path.relpath(source, Path.cwd()).replace(os.sep, "/")
1535
- if not import_path.startswith("."):
1536
- import_path = f"./{import_path}"
1537
- if len(exports) == 1:
1538
- descriptor_name, inputs_interface = exports[0]
1539
- required_fields = _required_interface_fields(
1540
- generated, inputs_interface
1541
- )
1542
- if required_fields is None:
1543
- return [
1544
- f'open "{source.as_posix()}" and import {descriptor_name}',
1545
- "read its generated inputs interface before calling connector; "
1546
- "the required-input skeleton is unavailable",
1547
- ]
1548
- inputs = "{}"
1549
- if required_fields:
1550
- placeholders = ", ".join(
1551
- f"{name}: /* {field_type}, required */"
1552
- for name, field_type in required_fields
1553
- )
1554
- inputs = f"{{ {placeholders} }}"
1555
- return [
1556
- f'import {{ {descriptor_name} }} from "{import_path}";',
1557
- f'connector({descriptor_name}, {inputs}, '
1558
- f'{{ connection: "{bound_connection}", folder: "{bound_folder}" }})',
1559
- "use this descriptor as generated: it carries the resolved live "
1560
- "operation and object identity even when the baked action id differs",
1561
- ]
1562
-
1563
- return [
1564
- f'open "{source.as_posix()}" and import the descriptor for the object you need',
1565
- "use that descriptor with connector(descriptor, inputs, opts); do not "
1566
- "reconstruct its live operation/object identity from the baked catalog",
1567
- ]
1568
-
1569
-
1570
- def _required_interface_fields(
1571
- generated: str, interface_name: str
1572
- ) -> list[tuple[str, str]] | None:
1573
- match = re.search(
1574
- rf"^export interface {re.escape(interface_name)} \{{(?P<body>.*?)^\}}",
1575
- generated,
1576
- flags=re.MULTILINE | re.DOTALL,
1577
- )
1578
- if not match:
1579
- return None
1580
-
1581
- required: list[tuple[str, str]] = []
1582
- property_pattern = re.compile(
1583
- r'^ (?P<name>[A-Za-z_$][A-Za-z0-9_$]*|"(?:\\.|[^"\\])*")'
1584
- r'(?P<optional>\?)?: (?P<type>.+);$'
1585
- )
1586
- for line in match.group("body").splitlines():
1587
- stripped = line.strip()
1588
- if not stripped or stripped.startswith(("/**", "*", "*/")):
1589
- continue
1590
- field = property_pattern.match(line)
1591
- if not field:
1592
- return None
1593
- if not field.group("optional"):
1594
- required.append((field.group("name"), _unwrap_value(field.group("type"))))
1595
- return required
1596
-
1597
-
1598
- def _unwrap_value(ts_type: str) -> str:
1599
- """`ConnectorValue<string>` -> `string`, for the handoff skeleton.
1600
-
1601
- The generated property type says what the field ACCEPTS — the literal or an
1602
- expression — which is the right thing for the compiler and the wrong thing
1603
- for a one-line "what goes here" hint. An author reading
1604
- `/* ConnectorValue<string>, required */` learns less than one reading
1605
- `/* string, required */`, and the wrapper is not something they type.
1606
- """
1607
- match = re.fullmatch(r"Connector(?:Lookup)?Value<(.+)>", ts_type.strip())
1608
- return match.group(1) if match else ts_type
1609
-
1610
-
1611
- def _count_input_fields(
1612
- out_dir: str, node_type: str, object_name: str | None = None
1613
- ) -> int | None:
1614
- """Input-field count across matching overlay variants."""
1615
- try:
1616
- with open(os.path.join(out_dir, "index.json")) as index_file:
1617
- idx = json.load(index_file)
1618
- entries = idx["entries"]
1619
- if not isinstance(entries, list):
1620
- return None
1621
- total = 0
1622
- matched = False
1623
- for index_entry in entries:
1624
- if index_entry["nodeType"] != node_type:
1625
- continue
1626
- if object_name is not None and index_entry.get("objectName") != object_name:
1627
- continue
1628
- matched = True
1629
- with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
1630
- descriptor = json.load(entry_file)
1631
- if not isinstance(descriptor, dict):
1632
- return None
1633
- input_schema = descriptor.get("inputSchema")
1634
- if not isinstance(input_schema, dict):
1635
- return None
1636
- fields = input_schema.get("fields")
1637
- if not isinstance(fields, list):
1638
- return None
1639
- total += len(fields)
1640
- return total if matched else None
1641
- except (OSError, json.JSONDecodeError, KeyError, TypeError):
1642
- return None
1643
-
1644
-
1645
- def _declared_parents(registry_path: str) -> list[str]:
1646
- """Parent-input names the registry declares through api-type ObjectActions.
1647
-
1648
- The second way an operation says "my fields depend on a value": not a
1649
- `{placeholder}` in a reference path (Jira), but a design-time action whose
1650
- rules name the inputs it reads (Data Fabric `create-entity-record` declares
1651
- `GenerateSchema` over `entityName`). `_object_action_specs` already reads
1652
- them for the replay request; this is the same list, flattened and in
1653
- declaration order, for the no-`-f` note.
1654
- """
1655
- names: list[str] = []
1656
- for _action, refs in _object_action_specs(registry_path):
1657
- for ref in refs:
1658
- if ref not in names:
1659
- names.append(ref)
1660
- return names
1661
-
1662
-
1663
- def _schema_action_parents(
1664
- out_dir: str, node_type: str, object_name: str | None = None
1665
- ) -> list[str]:
1666
- """Parent inputs the PREPARED operation's own schema action names.
1667
-
1668
- The third and most direct way an operation says "my fields depend on a
1669
- value", and the only one that is a PRECONDITION rather than a symptom: the
1670
- descriptor just written carries `operation.schemaAction.parents`, so with
1671
- any of those missing from `-f` the action cannot have run, whatever the
1672
- describe returned. `_parent_fields` and `_declared_parents` both read the
1673
- result or the registry instead, and each has a shape it cannot see:
1674
-
1675
- - `_parent_fields` asks "is EVERY prepared field a parent?". Jira's
1676
- `create-issue` comes back parent-only PLUS `key` (its own issue key), so
1677
- that test reads an unresolved schema as resolved and says nothing while
1678
- `compile` is about to refuse `summary` as an unknown input — measured on
1679
- a real run that then fell back to the generic `insert-record`.
1680
- - `_declared_parents` reads the registry's api-type ObjectActions, which is
1681
- empty for that operation.
1682
-
1683
- Order is the declaration's, which is also the resolution order (a Jira
1684
- issue-type id is valid only inside its project).
1685
- """
1686
- names: list[str] = []
1687
- try:
1688
- with open(os.path.join(out_dir, "index.json")) as index_file:
1689
- entries = json.load(index_file)["entries"]
1690
- for index_entry in entries:
1691
- if index_entry["nodeType"] != node_type:
1692
- continue
1693
- if object_name is not None and index_entry.get("objectName") != object_name:
1694
- continue
1695
- with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
1696
- descriptor = json.load(entry_file)
1697
- action = ((descriptor.get("operation") or {}).get("schemaAction")) or {}
1698
- for name in action.get("parents") or []:
1699
- if name and name not in names:
1700
- names.append(name)
1701
- except (OSError, KeyError, TypeError, ValueError):
1702
- return []
1703
- return names
1704
-
1705
-
1706
- def _keep_prepared_schema(
1707
- args, node_type: str, staged: str, parent_fields: dict[str, str],
1708
- ) -> tuple[int, int] | None:
1709
- """`(kept, discarded)` when this run must not overwrite the overlay, else `None`.
1710
-
1711
- `--resolve` records a lookup; `-f` supplies the value a schema action reads.
1712
- A run carrying only the former cannot have run the action, so what it has to
1713
- write is the UNRESOLVED entry — and `merge_into` replaces by variant key, so
1714
- it lands on top of a schema an earlier `-f` run resolved. Measured on Jira
1715
- `create-issue`: 21 fields down to 3, taking the live-only `fields.reporter.id`
1716
- and `fields.summary` out of `required` with it, after which `compile` accepts
1717
- a flow it had been refusing (#666).
1718
-
1719
- The loop that provokes it is the documented one. `check` prints
1720
- `prepare … --resolve <field>:<by>=<value>` for every unresolved lookup, with
1721
- no `-f`, so an author who prepares with `-f`, authors against the live schema
1722
- and then does what `check` says destroys the schema they just authored
1723
- against. #685 made that run say so; it still wrote.
1724
-
1725
- Three conditions, all necessary:
1726
-
1727
- * the EXISTING overlay declares `schemaAction.parents` — read from
1728
- `args.out` before the merge, so this is the entry about to be replaced;
1729
- * none of those parents arrived as `-f`, which is what makes "the action did
1730
- not run" a fact about the inputs rather than a guess about the result;
1731
- * the staged entry has FEWER input fields than the one on disk.
1732
-
1733
- The third keeps this a strict no-loss rule rather than a blanket skip: equal
1734
- or richer still merges, so a re-pulled library's fresher entry is never
1735
- pinned by a stale overlay, and a first `--resolve` on an operation with no
1736
- overlay writes normally (`_count_input_fields` answers `None`).
1737
- """
1738
- parents = _schema_action_parents(args.out, node_type, args.object)
1739
- if not parents or any(name in parent_fields for name in parents):
1740
- return None
1741
- existing = _count_input_fields(args.out, node_type, args.object)
1742
- incoming = _count_input_fields(staged, node_type, args.object)
1743
- if existing is None or incoming is None or existing <= incoming:
1744
- return None
1745
- return existing, incoming
1746
-
1747
-
1748
- def _parent_fields(
1749
- out_dir: str, node_type: str, object_name: str | None = None
1750
- ) -> tuple[list[str], bool]:
1751
- """`(parent field names, whether the prepared schema is parent-ONLY)`.
1752
-
1753
- A reference `path` carrying a `{placeholder}` is the declaration that an
1754
- operation is parent-driven: `"/project/{fields.project.key}/issuetypes"` on
1755
- `fields.issuetype.id` names the field whose value must be resolved first,
1756
- and so also fixes the resolution ORDER. (Only `path` is scanned;
1757
- `filterPattern` carries an unrelated `{filter}` template.)
1758
-
1759
- The parent set is both halves of that relation — the placeholder names AND
1760
- the fields that declare them — because both must be passed as `-f`.
1761
- Reporting only the placeholders would name `fields.project.key` and omit
1762
- `fields.issuetype.id`, the very field that declared it.
1763
-
1764
- "Parent-only" then means every prepared field is a parent: the describe gave
1765
- back nothing but the inputs that select the real schema. That is the
1766
- expected shape with no `-f`, and a silent failure WITH `-f` — values that do
1767
- not exist on the connection are accepted and resolve to exactly this.
1768
- A resolved schema keeps its parents (Jira's 19 still include both), so
1769
- subset-of-parents is the discriminator, not the presence of a placeholder.
1770
- """
1771
- names: list[str] = []
1772
- placeholders: set[str] = set()
1773
- declaring: set[str] = set()
1774
- try:
1775
- with open(os.path.join(out_dir, "index.json")) as index_file:
1776
- entries = json.load(index_file)["entries"]
1777
- for index_entry in entries:
1778
- if index_entry["nodeType"] != node_type:
1779
- continue
1780
- if object_name is not None and index_entry.get("objectName") != object_name:
1781
- continue
1782
- with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
1783
- descriptor = json.load(entry_file)
1784
- for field in descriptor.get("inputSchema", {}).get("fields", []) or []:
1785
- name = field.get("name")
1786
- if name and name not in names:
1787
- names.append(name)
1788
- path = ((field.get("reference") or {}).get("path")) or ""
1789
- found = re.findall(r"\{([^{}]+)\}", path)
1790
- if found:
1791
- placeholders.update(found)
1792
- if name:
1793
- declaring.add(name)
1794
- except (OSError, KeyError, TypeError, ValueError):
1795
- return [], False
1796
-
1797
- parents = placeholders | declaring
1798
- if not parents:
1799
- return [], False
1800
- ordered = [name for name in names if name in parents]
1801
- ordered += sorted(parents - set(ordered))
1802
- return ordered, set(names) <= parents
1803
-
1804
-
1805
- def _parse_fields(pairs: list[str]) -> dict[str, str]:
1806
- """`['a=1', 'b=2']` → `{'a': '1', 'b': '2'}`, rejecting a missing `=`.
1807
-
1808
- Values may themselves contain `=` (a Salesforce SOQL body token), so split
1809
- on the FIRST separator only.
1810
- """
1811
- parsed: dict[str, str] = {}
1812
- for pair in pairs:
1813
- name, sep, value = pair.partition("=")
1814
- if not sep or not name.strip():
1815
- sys.exit(
1816
- f"prepare-connector: --field expects NAME=VALUE, got {pair!r}."
1817
- )
1818
- parsed[name.strip()] = value
1819
- return parsed
1820
-
1821
-
1822
- def _rejected_parent_fields_message(
1823
- pairs: list[str], failures: list[tuple] | None = None
1824
- ) -> str:
1825
- """Diagnose a rejected parent-field set, preserving the CLI's own message.
1826
-
1827
- An incomplete or invalid set is refused outright — "No api-type ObjectAction
1828
- matched for fields [...] on operation 'Create'" — which already names the
1829
- fields it could not match. That message never reaches the caller on its own:
1830
- `is_describe_operation` maps the failure to `None` and enrichment falls back
1831
- to the registry-declared fields, so the overlay is written as though nothing
1832
- went wrong. Fail here, and quote the service.
1833
- """
1834
- given = ", ".join(pairs)
1835
- detail = ""
1836
- for failure in failures or []:
1837
- message = failure[-1]
1838
- if message:
1839
- detail = f" The service said: {message}"
1840
- break
1841
- return (
1842
- f"prepare-connector: the parent-field set was rejected: {given}.{detail} "
1843
- "Re-run with NO -f to list the parent fields this operation declares, "
1844
- "resolve a value for EVERY one of them, then pass them all together. "
1845
- "Values are connection- and parent-scoped (a Jira issue-type id is valid "
1846
- "only inside its project), so they cannot be copied from another "
1847
- "project, connection, or task."
1848
- )
1849
-
1850
-
1851
- def _input_field_note(count: int | None) -> str:
1852
- return (
1853
- f"{count} input field(s)"
1854
- if count is not None
1855
- else "input fields: <unavailable>"
1856
- )
1857
-
1858
-
1859
- if __name__ == "__main__":
1860
- main()