@uipath/maestro-builder-sdk 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/bindings-v2.d.ts +37 -0
- package/dist/bindings-v2.js +155 -0
- package/dist/bindings.d.ts +6 -0
- package/dist/bindings.js +6 -0
- package/dist/bpmn/bpmn-expr-check.d.ts +41 -0
- package/dist/bpmn/bpmn-expr-check.js +218 -0
- package/dist/bpmn/bpmn-sdk.d.ts +2257 -0
- package/dist/bpmn/bpmn-sdk.js +1476 -0
- package/dist/bpmn/check-cli.d.ts +3 -0
- package/dist/bpmn/check-cli.js +32 -0
- package/dist/bpmn/check.d.ts +25 -0
- package/dist/bpmn/check.js +430 -0
- package/dist/bpmn/compile-cli.d.ts +3 -0
- package/dist/bpmn/compile-cli.js +60 -0
- package/dist/bpmn/connector.d.ts +56 -0
- package/dist/bpmn/connector.js +199 -0
- package/dist/bpmn/decompile-cli.d.ts +8 -0
- package/dist/bpmn/decompile-cli.js +54 -0
- package/dist/bpmn/decompile.d.ts +6 -0
- package/dist/bpmn/decompile.js +1108 -0
- package/dist/bpmn/format-profile.d.ts +27 -0
- package/dist/bpmn/format-profile.js +11 -0
- package/dist/bpmn/intsvc.d.ts +77 -0
- package/dist/bpmn/intsvc.js +155 -0
- package/dist/bpmn/load.d.ts +4 -0
- package/dist/bpmn/load.js +22 -0
- package/dist/bpmn/merge-cli.d.ts +3 -0
- package/dist/bpmn/merge-cli.js +45 -0
- package/dist/bpmn/merge.d.ts +19 -0
- package/dist/bpmn/merge.js +293 -0
- package/dist/bpmn/registry-types.json +1869 -0
- package/dist/bpmn/serialize.d.ts +28 -0
- package/dist/bpmn/serialize.js +757 -0
- package/dist/bpmn/tidy-cli.d.ts +2 -0
- package/dist/bpmn/tidy-cli.js +39 -0
- package/dist/bpmn/typed-node.d.ts +382 -0
- package/dist/bpmn/typed-node.js +551 -0
- package/dist/bpmn/uipath-moddle.v1.json +823 -0
- package/dist/bpmn/xml-ranges.d.ts +45 -0
- package/dist/bpmn/xml-ranges.js +151 -0
- package/dist/case/case-expr-check.d.ts +35 -0
- package/dist/case/case-expr-check.js +85 -0
- package/dist/case/case-sdk.d.ts +1282 -0
- package/dist/case/case-sdk.js +923 -0
- package/dist/case/check-cli.d.ts +3 -0
- package/dist/case/check-cli.js +31 -0
- package/dist/case/check.d.ts +18 -0
- package/dist/case/check.js +456 -0
- package/dist/case/compile-cli.d.ts +3 -0
- package/dist/case/compile-cli.js +291 -0
- package/dist/case/decompile-cli.d.ts +9 -0
- package/dist/case/decompile-cli.js +117 -0
- package/dist/case/decompile.d.ts +187 -0
- package/dist/case/decompile.js +1170 -0
- package/dist/case/entry-points.d.ts +79 -0
- package/dist/case/entry-points.js +118 -0
- package/dist/case/format-profile.d.ts +60 -0
- package/dist/case/format-profile.js +79 -0
- package/dist/case/ids.d.ts +24 -0
- package/dist/case/ids.js +76 -0
- package/dist/case/load.d.ts +12 -0
- package/dist/case/load.js +32 -0
- package/dist/case/preserve.d.ts +133 -0
- package/dist/case/preserve.js +105 -0
- package/dist/case/serialize.d.ts +10 -0
- package/dist/case/serialize.js +1414 -0
- package/dist/check-cli.d.ts +3 -0
- package/dist/check-cli.js +118 -0
- package/dist/check.d.ts +146 -0
- package/dist/check.js +4898 -0
- package/dist/cli/commands/bpmn.d.ts +2 -0
- package/dist/cli/commands/bpmn.js +73 -0
- package/dist/cli/commands/case.d.ts +2 -0
- package/dist/cli/commands/case.js +60 -0
- package/dist/cli/commands/flow.d.ts +2 -0
- package/dist/cli/commands/flow.js +99 -0
- package/dist/cli/commands/registry.d.ts +10 -0
- package/dist/cli/commands/registry.js +170 -0
- package/dist/cli/common.d.ts +30 -0
- package/dist/cli/common.js +92 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli-run.d.ts +2 -0
- package/dist/cli-run.js +42 -0
- package/dist/compile-cli.d.ts +3 -0
- package/dist/compile-cli.js +160 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +201 -0
- package/dist/core/actions.d.ts +3244 -0
- package/dist/core/actions.js +1062 -0
- package/dist/core/binding-messages.d.ts +1 -0
- package/dist/core/binding-messages.js +16 -0
- package/dist/core/bindings.d.ts +12 -0
- package/dist/core/bindings.js +67 -0
- package/dist/core/ceql.d.ts +73 -0
- package/dist/core/ceql.js +272 -0
- package/dist/core/cli-spelling.d.ts +39 -0
- package/dist/core/cli-spelling.js +62 -0
- package/dist/core/connector-raw-node.d.ts +21 -0
- package/dist/core/connector-raw-node.js +47 -0
- package/dist/core/connectors.d.ts +184 -0
- package/dist/core/connectors.js +128 -0
- package/dist/core/expr-check.d.ts +93 -0
- package/dist/core/expr-check.js +186 -0
- package/dist/core/expr.d.ts +323 -0
- package/dist/core/expr.js +400 -0
- package/dist/core/index.d.ts +10 -0
- package/dist/core/index.js +10 -0
- package/dist/core/library.d.ts +440 -0
- package/dist/core/library.js +1183 -0
- package/dist/core/list-envelope.d.ts +45 -0
- package/dist/core/list-envelope.js +97 -0
- package/dist/core/lookup-store.d.ts +22 -0
- package/dist/core/lookup-store.js +85 -0
- package/dist/core/lookups.d.ts +227 -0
- package/dist/core/lookups.js +231 -0
- package/dist/core/node-classes.d.ts +234 -0
- package/dist/core/node-classes.js +158 -0
- package/dist/core/script-return.d.ts +68 -0
- package/dist/core/script-return.js +197 -0
- package/dist/core/stable-id.d.ts +28 -0
- package/dist/core/stable-id.js +37 -0
- package/dist/core-definitions.json +16249 -0
- package/dist/decompile-cli.d.ts +3 -0
- package/dist/decompile-cli.js +131 -0
- package/dist/decompile.d.ts +138 -0
- package/dist/decompile.js +2482 -0
- package/dist/emit.d.ts +10 -0
- package/dist/emit.js +60 -0
- package/dist/event-filters.d.ts +130 -0
- package/dist/event-filters.js +137 -0
- package/dist/flow-expr-check.d.ts +17 -0
- package/dist/flow-expr-check.js +193 -0
- package/dist/flow-sdk.d.ts +1341 -0
- package/dist/flow-sdk.js +1164 -0
- package/dist/flow-variables.d.ts +13 -0
- package/dist/flow-variables.js +42 -0
- package/dist/format-profile.d.ts +72 -0
- package/dist/format-profile.js +97 -0
- package/dist/generators/_connections.py +287 -0
- package/dist/generators/_events.py +355 -0
- package/dist/generators/_lookups.py +114 -0
- package/dist/generators/_naming.py +185 -0
- package/dist/generators/_objects.py +299 -0
- package/dist/generators/_resolve.py +273 -0
- package/dist/generators/convert_library_to_md.py +1441 -0
- package/dist/generators/extract_library.py +125 -0
- package/dist/generators/generate_connectors_ts.py +936 -0
- package/dist/generators/generate_library_json.py +2527 -0
- package/dist/generators/generate_triggers_ts.py +110 -0
- package/dist/generators/materialize_registry.py +155 -0
- package/dist/generators/prepare_connector.py +1688 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/ixp-definition.json +1982 -0
- package/dist/library.d.ts +6 -0
- package/dist/library.js +6 -0
- package/dist/load.d.ts +16 -0
- package/dist/load.js +95 -0
- package/dist/merge-cli.d.ts +3 -0
- package/dist/merge-cli.js +47 -0
- package/dist/merge.d.ts +125 -0
- package/dist/merge.js +0 -0
- package/dist/migrate.d.ts +15 -0
- package/dist/migrate.js +245 -0
- package/dist/node-runtime.d.ts +3 -0
- package/dist/node-runtime.js +22 -0
- package/dist/package-name.d.ts +17 -0
- package/dist/package-name.js +17 -0
- package/dist/ref-resolve.d.ts +77 -0
- package/dist/ref-resolve.js +170 -0
- package/dist/registry/cache.d.ts +55 -0
- package/dist/registry/cache.js +134 -0
- package/dist/registry/curated-overlay.d.ts +16 -0
- package/dist/registry/curated-overlay.js +62 -0
- package/dist/registry/extractor.d.ts +22 -0
- package/dist/registry/extractor.js +18 -0
- package/dist/registry/materialize-bin.d.ts +2 -0
- package/dist/registry/materialize-bin.js +7 -0
- package/dist/registry/materialize-cli.d.ts +1 -0
- package/dist/registry/materialize-cli.js +46 -0
- package/dist/registry/materializer.d.ts +23 -0
- package/dist/registry/materializer.js +34 -0
- package/dist/registry/path-cli.d.ts +1 -0
- package/dist/registry/path-cli.js +56 -0
- package/dist/registry/prepare-cli.d.ts +62 -0
- package/dist/registry/prepare-cli.js +185 -0
- package/dist/registry/pull-cli.d.ts +1 -0
- package/dist/registry/pull-cli.js +274 -0
- package/dist/registry/python.d.ts +26 -0
- package/dist/registry/python.js +46 -0
- package/dist/registry/search-cli.d.ts +52 -0
- package/dist/registry/search-cli.js +159 -0
- package/dist/schema-version.d.ts +165 -0
- package/dist/schema-version.js +148 -0
- package/dist/serialize.d.ts +64 -0
- package/dist/serialize.js +6398 -0
- package/lib/README.md +112 -0
- package/lib/library-json.samples/index.json +158 -0
- package/lib/library-json.samples/invariant-collections.json +83 -0
- package/lib/library-json.samples/list-envelopes.json +123 -0
- package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.json +131 -0
- package/lib/library-json.samples/uipath-atlassian-jira/create-issue@1.0.0.v1def.json +306 -0
- package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.json +86 -0
- package/lib/library-json.samples/uipath-atlassian-jira/generic/create_issue/create@1.0.0.v1def.json +208 -0
- package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.json +2040 -0
- package/lib/library-json.samples/uipath-atlassian-jira/get-issue@1.0.0.v1def.json +195 -0
- package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.json +169 -0
- package/lib/library-json.samples/uipath-google-vertex/execute-google-vertex-agent@1.0.0.v1def.json +209 -0
- package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.json +63 -0
- package/lib/library-json.samples/uipath-http-webhook/event-http-webhook@1.0.0.v1def.json +262 -0
- package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.json +63 -0
- package/lib/library-json.samples/uipath-http-webhook/trigger-http-webhook@1.0.0.v1def.json +213 -0
- package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.json +227 -0
- package/lib/library-json.samples/uipath-microsoft-azureactivedirectory/list-groups@1.0.0.v1def.json +373 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.json +116 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-file-created@1.0.0.v1def.json +369 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.json +56 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/event-list-item-added@1.0.0.v1def.json +256 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.json +116 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-file-created@1.0.0.v1def.json +320 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.json +56 -0
- package/lib/library-json.samples/uipath-microsoft-onedrive/trigger-list-item-added@1.0.0.v1def.json +207 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.json +107 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/event-email-received@1.0.0.v1def.json +264 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.json +142 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/send-email@1.0.0.v1def.json +306 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.json +107 -0
- package/lib/library-json.samples/uipath-microsoft-outlook365/trigger-email-received@1.0.0.v1def.json +217 -0
- package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.json +85 -0
- package/lib/library-json.samples/uipath-microsoft-powerautomate/invoke-a-microsoft-power-automate-flow@1.0.0.v1def.json +209 -0
- package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.json +755 -0
- package/lib/library-json.samples/uipath-salesforce-slack/send-message-to-user@1.0.0.v1def.json +513 -0
- package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.json +122 -0
- package/lib/library-json.samples/uipath-uipath-dataservice/query-entity-records@1.0.0.v1def.json +227 -0
- package/package.json +85 -0
|
@@ -0,0 +1,1688 @@
|
|
|
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
|
+
if not args.connection_id:
|
|
386
|
+
try:
|
|
387
|
+
found = _connections.discover_connection(args.key, args.connection)
|
|
388
|
+
except _connections.ConnectionError_ as error:
|
|
389
|
+
sys.exit(f"prepare-connector: {error}")
|
|
390
|
+
args.connection_id = str(found["Id"])
|
|
391
|
+
print(f" connection: {found.get('Name')} ({args.connection_id})")
|
|
392
|
+
else:
|
|
393
|
+
# An explicit id gets the same gate discovery applies. Without it a
|
|
394
|
+
# `State: Failed` connection went straight to `is resources describe`,
|
|
395
|
+
# which answers Success with NO request fields for a connection the
|
|
396
|
+
# tenant cannot reach — so the overlay was written with zero body
|
|
397
|
+
# inputs, reported as "0 input field(s)" success, and the next compile
|
|
398
|
+
# rejected every real field as unknown (skill-flow-ixp-routing-negative/
|
|
399
|
+
# sf-update: Salesforce update-record on a Failed connection, 0 fields;
|
|
400
|
+
# the Enabled connection carries 13, StageName among them).
|
|
401
|
+
try:
|
|
402
|
+
found = _connections.lookup_connection(args.key, args.connection_id)
|
|
403
|
+
except _connections.ConnectionError_ as error:
|
|
404
|
+
sys.exit(f"prepare-connector: {error}")
|
|
405
|
+
print(f" connection: {found.get('Name')} ({args.connection_id})")
|
|
406
|
+
|
|
407
|
+
# Record the connection in bindings.json on BOTH routes. Until 3.30.5 only
|
|
408
|
+
# discovery wrote it: an explicit `--connection-id` — the one way past two
|
|
409
|
+
# connections that share a name — left the author hand-writing the id and
|
|
410
|
+
# folder key, and the hint below printed `<binding-name>` placeholders that
|
|
411
|
+
# went straight into source and bindings.json (measured on the eval corpus:
|
|
412
|
+
# angle-bracket and all-zero "GUIDs" in 16 archived bindings.json files).
|
|
413
|
+
if not args.no_bindings:
|
|
414
|
+
# Taken before the first write so a later refusal can undo exactly this
|
|
415
|
+
# command's changes, and only them.
|
|
416
|
+
_snapshot_bindings(args)
|
|
417
|
+
symbolic = args.bind_connection or args.key.rsplit("-", 1)[-1]
|
|
418
|
+
try:
|
|
419
|
+
wrote_conn, wrote_folder = _connections.merge_bindings(
|
|
420
|
+
args.bindings, found, symbolic, args.bind_folder)
|
|
421
|
+
except _connections.ConnectionError_ as error:
|
|
422
|
+
sys.exit(f"prepare-connector: {error}")
|
|
423
|
+
args._bound_connection = symbolic
|
|
424
|
+
args._bound_folder = args.bind_folder
|
|
425
|
+
for name, changed in ((symbolic, wrote_conn),
|
|
426
|
+
(args.bind_folder, wrote_folder)):
|
|
427
|
+
if changed:
|
|
428
|
+
print(f" bindings: {name} -> {args.bindings}")
|
|
429
|
+
|
|
430
|
+
gen.set_connection_id(args.connection_id)
|
|
431
|
+
gen.set_parent_fields(parent_fields)
|
|
432
|
+
gen.reset_failed_field_describes()
|
|
433
|
+
node_type = f"uipath.connector.{args.key}.{args.action}"
|
|
434
|
+
|
|
435
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
436
|
+
cache = os.path.join(tmp, "reg")
|
|
437
|
+
is_cache = os.path.join(tmp, "is")
|
|
438
|
+
os.makedirs(cache)
|
|
439
|
+
os.makedirs(is_cache)
|
|
440
|
+
|
|
441
|
+
path = gen.registry_get(node_type, cache, quiet=True)
|
|
442
|
+
if not path:
|
|
443
|
+
# Not an activity. Connector EVENTS live one namespace over —
|
|
444
|
+
# `uipath.connector.trigger.<key>.<event>` / `.event.<key>.<event>`
|
|
445
|
+
# — and their registry definition only answers with a connection,
|
|
446
|
+
# which this command has by now. Same loop, different twin pair.
|
|
447
|
+
if _prepare_event(args, tmp, cache):
|
|
448
|
+
return
|
|
449
|
+
sys.exit(_unresolved_action_message(args.key, args.action, node_type))
|
|
450
|
+
# The registry's own declaration of parent inputs, read while the file
|
|
451
|
+
# still exists: `path` lives in this temporary directory.
|
|
452
|
+
declared_parents = _declared_parents(path)
|
|
453
|
+
entry = gen.extract_canonical(path)
|
|
454
|
+
if not entry:
|
|
455
|
+
sys.exit(f"prepare-connector: no canonical entry for {node_type} (unexpected registry shape).")
|
|
456
|
+
if args.version:
|
|
457
|
+
entry["version"] = args.version
|
|
458
|
+
if parent_fields:
|
|
459
|
+
request = _object_action_request(path, parent_fields)
|
|
460
|
+
if not request:
|
|
461
|
+
expected = [
|
|
462
|
+
", ".join(fields)
|
|
463
|
+
for _name, fields in _object_action_specs(path)
|
|
464
|
+
]
|
|
465
|
+
expected_text = " or ".join(expected) or "<none advertised>"
|
|
466
|
+
sys.exit(
|
|
467
|
+
"prepare-connector: parent-field names must match one "
|
|
468
|
+
"api-type ObjectAction exactly. Given: "
|
|
469
|
+
f"{', '.join(parent_fields)}. Expected: {expected_text}."
|
|
470
|
+
)
|
|
471
|
+
entry["operation"]["customFieldsRequestDetails"] = request
|
|
472
|
+
|
|
473
|
+
is_expandable_generic = (
|
|
474
|
+
entry["operation"].get("activityType") == "Generic"
|
|
475
|
+
and not gen._is_http_passthrough(entry)
|
|
476
|
+
)
|
|
477
|
+
if is_expandable_generic:
|
|
478
|
+
if not args.object and not args.all_objects:
|
|
479
|
+
sys.exit(
|
|
480
|
+
"prepare-connector: this is a Generic action; pass "
|
|
481
|
+
"--object <name> to prepare one object or --all-objects "
|
|
482
|
+
"to prepare the full connected catalog."
|
|
483
|
+
)
|
|
484
|
+
templates = gen._generic_templates_by_op([entry]).get(args.key, {})
|
|
485
|
+
if args.object:
|
|
486
|
+
# The author's name, not necessarily the API's: `Company__C`
|
|
487
|
+
# from a prompt is `Company__c` in the catalog, and equality
|
|
488
|
+
# used to dead-end on exactly that. Match it deterministically
|
|
489
|
+
# BEFORE expansion — expansion re-reads the same file-cached
|
|
490
|
+
# catalog, so the match costs no extra network.
|
|
491
|
+
verb = next(iter(templates)) if len(templates) == 1 else None
|
|
492
|
+
requested = args.object
|
|
493
|
+
matched_row = _match_catalog_object(
|
|
494
|
+
args.key, args.object, verb, is_cache
|
|
495
|
+
)
|
|
496
|
+
if matched_row is not None:
|
|
497
|
+
args.object = matched_row.get("Name") or args.object
|
|
498
|
+
args._object_requested = requested
|
|
499
|
+
args._object_row = matched_row
|
|
500
|
+
args._object_verb = verb
|
|
501
|
+
selected = None if args.all_objects else {args.object}
|
|
502
|
+
prepared_entries = gen.expand_generic_for_connector(
|
|
503
|
+
args.key, templates, is_cache, object_names=selected
|
|
504
|
+
)
|
|
505
|
+
if not prepared_entries:
|
|
506
|
+
operation = entry["operation"].get("name") or args.action
|
|
507
|
+
sys.exit(_missing_generic_schema_message(
|
|
508
|
+
operation,
|
|
509
|
+
None if args.all_objects else args.object,
|
|
510
|
+
))
|
|
511
|
+
else:
|
|
512
|
+
if args.object or args.all_objects:
|
|
513
|
+
sys.exit(
|
|
514
|
+
"prepare-connector: --object/--all-objects apply only to "
|
|
515
|
+
"Generic connector actions."
|
|
516
|
+
)
|
|
517
|
+
gen.enrich_entry(entry, is_cache) # full schema via --connection-id
|
|
518
|
+
prepared_entries = [entry]
|
|
519
|
+
|
|
520
|
+
tmplib = os.path.join(tmp, "lib")
|
|
521
|
+
gen.write_library(tmplib, prepared_entries, {entry["nodeType"]: path})
|
|
522
|
+
kept_schema = _keep_prepared_schema(
|
|
523
|
+
args, entry["nodeType"], tmplib, parent_fields,
|
|
524
|
+
)
|
|
525
|
+
if kept_schema is None:
|
|
526
|
+
merge_into(tmplib, args.out)
|
|
527
|
+
|
|
528
|
+
# The object decision is the FIRST resolution — recorded before any
|
|
529
|
+
# lookup rows, because it is their prerequisite: the schema the lookups
|
|
530
|
+
# were generated from is the one this match selected.
|
|
531
|
+
if getattr(args, "_object_row", None) is not None:
|
|
532
|
+
try:
|
|
533
|
+
ledger_path = _objects.record_object_resolution(
|
|
534
|
+
args.out, node_type, args._object_requested,
|
|
535
|
+
args._object_row, args._object_verb,
|
|
536
|
+
)
|
|
537
|
+
except _objects.LedgerError as error:
|
|
538
|
+
sys.exit(f"prepare-connector: {error}")
|
|
539
|
+
print(f" object: {args._object_requested} -> {args.object} "
|
|
540
|
+
f"recorded in {ledger_path}")
|
|
541
|
+
|
|
542
|
+
if args.resolve:
|
|
543
|
+
_write_resolutions(args, node_type)
|
|
544
|
+
|
|
545
|
+
_regenerate_descriptors(args)
|
|
546
|
+
n_fields = _count_input_fields(args.out, entry["nodeType"], args.object)
|
|
547
|
+
n_variants = len(prepared_entries)
|
|
548
|
+
ts_note = "JSON overlay only" if args.json_only else f"overlay + {args.key}.ts"
|
|
549
|
+
field_note = _input_field_note(n_fields)
|
|
550
|
+
# The describe is the evidence, not the finished field count. A rejected
|
|
551
|
+
# parent set leaves `enrich_entry` returning the registry-DECLARED fields,
|
|
552
|
+
# so the overlay looks plausible (measured: project-only on Jira
|
|
553
|
+
# create-issue writes 3 declared fields) while the real answer was a
|
|
554
|
+
# Failure. Surface the CLI's own message instead of that quiet success.
|
|
555
|
+
failures = gen.failed_field_describes()
|
|
556
|
+
if args.field and (failures or n_fields == 0):
|
|
557
|
+
sys.exit(_rejected_parent_fields_message(args.field, failures))
|
|
558
|
+
print(
|
|
559
|
+
f"prepare-connector: {node_type} → {args.out}/ "
|
|
560
|
+
f"({ts_note}, {n_variants} variant(s), {field_note})"
|
|
561
|
+
)
|
|
562
|
+
if kept_schema is not None:
|
|
563
|
+
kept, discarded = kept_schema
|
|
564
|
+
print(
|
|
565
|
+
f"prepare-connector: kept the prepared schema for {args.action} "
|
|
566
|
+
f"({kept} input field(s)) — this run passed no -f, so its schema "
|
|
567
|
+
f"action did not run and the unresolved entry ({discarded} field(s)) "
|
|
568
|
+
f"would have replaced it. The resolutions above are recorded either "
|
|
569
|
+
f"way; re-run with -f to refresh the schema itself."
|
|
570
|
+
)
|
|
571
|
+
_print_lookup_vocabulary(args, node_type)
|
|
572
|
+
# Whether this operation has parent fields is answered by what we just
|
|
573
|
+
# prepared, not by a catalog flag: `extract_canonical` does not run the bulk
|
|
574
|
+
# discovery-marking pass, so `requiresConnectionForSchema` is still False
|
|
575
|
+
# here. A reference path carrying a {placeholder} IS the declaration.
|
|
576
|
+
parents, parent_only = _parent_fields(
|
|
577
|
+
args.out, entry["nodeType"], args.object
|
|
578
|
+
)
|
|
579
|
+
if parent_only and args.field:
|
|
580
|
+
# The set was SHAPED right (nothing was rejected) but resolved to
|
|
581
|
+
# nothing new, so at least one value does not exist on this connection.
|
|
582
|
+
sys.exit(
|
|
583
|
+
"prepare-connector: the parent-field values did not resolve — the "
|
|
584
|
+
"prepared schema is still parent-only (" + ", ".join(parents) +
|
|
585
|
+
f"). Given: {', '.join(args.field)}. The names were accepted, so a "
|
|
586
|
+
"VALUE is wrong for this connection: resolve each one against this "
|
|
587
|
+
"connection (ids are parent-scoped — a Jira issue-type id is valid "
|
|
588
|
+
"only inside its project) rather than reusing one from another "
|
|
589
|
+
"project, connection, or task."
|
|
590
|
+
)
|
|
591
|
+
# stdout is block-buffered under a pipe; flush so a `2>&1` reader sees the
|
|
592
|
+
# note after the overlay line it refers to, not before it.
|
|
593
|
+
sys.stdout.flush()
|
|
594
|
+
# Whether the schema action RAN is decided by its inputs, not by its
|
|
595
|
+
# output: every parent it names must have arrived as `-f`. Both earlier
|
|
596
|
+
# tests read the result instead, and between them let Jira's `create-issue`
|
|
597
|
+
# through silently — parent-only PLUS its own `key`, so "every field is a
|
|
598
|
+
# parent" was false and the registry declares no api-type ObjectAction for
|
|
599
|
+
# it either. The overlay then carried 3 of 19 inputs, `prepare` printed a
|
|
600
|
+
# clean "next:", and the author found out when `compile` refused `summary`.
|
|
601
|
+
# Union of both declaration sites so an overlay that omits `schemaAction`
|
|
602
|
+
# still gets the registry's answer.
|
|
603
|
+
action_parents = _schema_action_parents(args.out, entry["nodeType"], args.object)
|
|
604
|
+
action_parents += [
|
|
605
|
+
name for name in declared_parents if name not in action_parents
|
|
606
|
+
]
|
|
607
|
+
missing = [name for name in action_parents if name not in parent_fields]
|
|
608
|
+
# Silent when the schema was KEPT: the note's premise is that the overlay
|
|
609
|
+
# now carries only the unresolved entry, and it does not — the richer one
|
|
610
|
+
# survived. Telling an author to "materialize the full field set" they are
|
|
611
|
+
# already holding sends them round a loop that changes nothing.
|
|
612
|
+
if missing and kept_schema is None:
|
|
613
|
+
# A schema parent IS a lookup: the id `--resolve` just recorded is the
|
|
614
|
+
# very value `-f` wants. The generic "resolve X and re-run" note made
|
|
615
|
+
# an agent run this prepare twice and hand-copy the id between the two
|
|
616
|
+
# commands (observed: 10732 retyped from the first run's output into
|
|
617
|
+
# the second run's -f). When every parent already has a recorded
|
|
618
|
+
# resolution, print the re-run ready to paste instead.
|
|
619
|
+
resolved_parents = _resolved_parent_values(args.out, node_type, action_parents)
|
|
620
|
+
given = " ".join(
|
|
621
|
+
f"-f {name}={parent_fields[name]}"
|
|
622
|
+
for name in action_parents
|
|
623
|
+
if name in parent_fields
|
|
624
|
+
)
|
|
625
|
+
if set(resolved_parents) == set(action_parents):
|
|
626
|
+
fields_arg = " ".join(
|
|
627
|
+
f"-f {name}={resolved_parents[name]}" for name in action_parents
|
|
628
|
+
)
|
|
629
|
+
print(
|
|
630
|
+
"prepare-connector: note: this operation resolves its fields "
|
|
631
|
+
"from the VALUES of parent inputs, and the resolutions above "
|
|
632
|
+
"ARE those values. Re-run to materialize the full field set:\n"
|
|
633
|
+
f" npx flow-sdk registry prepare {args.key} {args.action} "
|
|
634
|
+
f"{fields_arg}",
|
|
635
|
+
file=sys.stderr,
|
|
636
|
+
)
|
|
637
|
+
else:
|
|
638
|
+
example = " ".join(
|
|
639
|
+
f"-f {name}={parent_fields.get(name, '<value>')}"
|
|
640
|
+
for name in action_parents
|
|
641
|
+
)
|
|
642
|
+
print(
|
|
643
|
+
"prepare-connector: note: this operation resolves its fields "
|
|
644
|
+
"from the VALUES of " + ", ".join(action_parents) +
|
|
645
|
+
" (its schema action), and " +
|
|
646
|
+
("none were given" if len(missing) == len(action_parents)
|
|
647
|
+
else f"{', '.join(missing)} " +
|
|
648
|
+
("was" if len(missing) == 1 else "were") + " not" +
|
|
649
|
+
(f" (given: {given})" if given else "")) +
|
|
650
|
+
", so the overlay above carries only the unresolved schema — a "
|
|
651
|
+
"body field the object declares is not there yet and compile "
|
|
652
|
+
"will refuse it as an unknown input. Re-run passing ALL of them "
|
|
653
|
+
"as -f NAME=VALUE (a partial set is rejected):\n"
|
|
654
|
+
f" npx flow-sdk registry prepare {args.key} {args.action} {example}",
|
|
655
|
+
file=sys.stderr,
|
|
656
|
+
)
|
|
657
|
+
if not args.json_only:
|
|
658
|
+
# The hint printed the placeholders `<binding-name>` /
|
|
659
|
+
# `<folder-binding-name>` even when this run had just WRITTEN both
|
|
660
|
+
# bindings and knew their names. Print what it bound, so the last line
|
|
661
|
+
# of output is a call the reader can paste.
|
|
662
|
+
usage = _descriptor_usage(
|
|
663
|
+
args.out, args.key,
|
|
664
|
+
getattr(args, "_bound_connection", None) or "<binding-name>",
|
|
665
|
+
getattr(args, "_bound_folder", None) or "<folder-binding-name>",
|
|
666
|
+
)
|
|
667
|
+
repointed = _repoint_source(args, args.key)
|
|
668
|
+
if repointed and repointed[0].startswith("repointed "):
|
|
669
|
+
# The edit is DONE, so telling the author to make it is now false.
|
|
670
|
+
# Drop the import line and keep the rest, which still teaches the
|
|
671
|
+
# call shape and the resolved-identity rule.
|
|
672
|
+
usage = [line for line in usage if not line.startswith("import {")]
|
|
673
|
+
for line in repointed:
|
|
674
|
+
print(f"prepare-connector: {line}")
|
|
675
|
+
for line in usage:
|
|
676
|
+
print(f"prepare-connector: next: {line}")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _print_lookup_vocabulary(args, node_type: str) -> None:
|
|
680
|
+
"""Print the lookup table for the operation just prepared.
|
|
681
|
+
|
|
682
|
+
The markdown catalog renders this table for BAKED schemas; a schema that
|
|
683
|
+
only exists because this command ran has no page to carry it, so the
|
|
684
|
+
command itself is where the vocabulary first becomes knowable. Without it
|
|
685
|
+
the author meets a custom object's lookup fields one `check` warning at a
|
|
686
|
+
time instead of all at once, here, beside the import line.
|
|
687
|
+
"""
|
|
688
|
+
specs = _lookup_specs_from_overlay(args.out, node_type, args.object)
|
|
689
|
+
if not specs:
|
|
690
|
+
return
|
|
691
|
+
where = f" ({args.object})" if args.object else ""
|
|
692
|
+
print(f"prepare-connector: lookup fields on {args.action}{where} — "
|
|
693
|
+
"these hold ids; author lookup(...) and resolve, don't paste one:")
|
|
694
|
+
shown = list(specs.items())[:12]
|
|
695
|
+
for field, spec in shown:
|
|
696
|
+
by = " | ".join(spec.get("by") or []) or "?"
|
|
697
|
+
target = f"{spec.get('objectName') or spec.get('path') or '?'}.{spec.get('value') or 'id'}"
|
|
698
|
+
print(f" {field} — matches on {by}, sends {target}")
|
|
699
|
+
if len(specs) > len(shown):
|
|
700
|
+
print(f" … and {len(specs) - len(shown)} more")
|
|
701
|
+
field, spec = shown[0]
|
|
702
|
+
by = (spec.get("by") or ["id"])[0]
|
|
703
|
+
obj = f" --object {args.object}" if args.object else ""
|
|
704
|
+
print("prepare-connector: resolve with: "
|
|
705
|
+
f"npx flow-sdk registry prepare {args.key} {args.action}{obj} "
|
|
706
|
+
f"--resolve {field}:{by}=<value>")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _resolved_parent_values(out_dir: str, node_type: str, parents) -> dict:
|
|
710
|
+
"""`{parent: resolved id}` for every parent with a recorded resolution.
|
|
711
|
+
|
|
712
|
+
Read from the ledger just written, so the paste-ready `-f` re-run carries
|
|
713
|
+
the ids THIS invocation resolved — never guessed, never retyped.
|
|
714
|
+
"""
|
|
715
|
+
try:
|
|
716
|
+
ledger = _objects.read_ledger(out_dir)
|
|
717
|
+
except _objects.LedgerError:
|
|
718
|
+
return {}
|
|
719
|
+
wanted = set(parents)
|
|
720
|
+
out: dict = {}
|
|
721
|
+
for key, row in (ledger.get("resolutions") or {}).items():
|
|
722
|
+
try:
|
|
723
|
+
key_node, field, _by, _match = json.loads(key)
|
|
724
|
+
except (ValueError, TypeError):
|
|
725
|
+
continue
|
|
726
|
+
if key_node == node_type and field in wanted:
|
|
727
|
+
out[field] = row.get("value")
|
|
728
|
+
return {k: v for k, v in out.items() if v not in (None, "")}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _match_catalog_object(key: str, wanted: str, verb: str | None, is_cache: str):
|
|
732
|
+
"""The catalog row `--object` names, matched by `_objects.match_object`.
|
|
733
|
+
|
|
734
|
+
Returns the row, or None when the catalog itself is unavailable — that
|
|
735
|
+
failure belongs to expansion, whose message already covers it. Every
|
|
736
|
+
refusal past the name rungs (zero matches, substring — unique or
|
|
737
|
+
ambiguous) is retried once against a refreshed catalog before it is
|
|
738
|
+
believed: the `uip is resources list` cache never invalidates itself, so
|
|
739
|
+
an object the tenant gained after the cache was written reads as
|
|
740
|
+
nonexistent (measured: 18 objects stale vs 864 fresh on
|
|
741
|
+
uipath-salesforce-sfdc). The one exception — which refusal is retriable is
|
|
742
|
+
the exception's call, not this loop's — is ambiguity WITHIN a name rung:
|
|
743
|
+
two case-variant names are a fact a refresh will not change.
|
|
744
|
+
"""
|
|
745
|
+
rows = gen.is_resources_list(key, is_cache, operation=verb) or []
|
|
746
|
+
if not rows:
|
|
747
|
+
return None
|
|
748
|
+
for attempt in ("cached", "refreshed"):
|
|
749
|
+
try:
|
|
750
|
+
row, note = _objects.match_object(rows, wanted, verb)
|
|
751
|
+
except _objects.ObjectMatchError as error:
|
|
752
|
+
if attempt == "cached" and error.retriable:
|
|
753
|
+
rows = gen.is_resources_list(
|
|
754
|
+
key, is_cache, operation=verb, refresh=True
|
|
755
|
+
) or rows
|
|
756
|
+
continue
|
|
757
|
+
sys.exit(f"prepare-connector: {error}")
|
|
758
|
+
if note:
|
|
759
|
+
print(f" object: {note}")
|
|
760
|
+
return row
|
|
761
|
+
return None
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _refuse_unless_byoa_connection(args) -> None:
|
|
765
|
+
"""Refuse a standard connection on an event object that requires BYOA.
|
|
766
|
+
|
|
767
|
+
V1 measured the failure this prevents: the trigger deploys and then faults
|
|
768
|
+
at runtime with a webhook error naming nothing about connections. The
|
|
769
|
+
requirement is knowable now, so it is refused now, with the connections
|
|
770
|
+
that WOULD work.
|
|
771
|
+
|
|
772
|
+
A refusal must leave nothing behind. Stage 0 has already written this
|
|
773
|
+
connection into `bindings.json` by the time the event's own metadata says
|
|
774
|
+
it is the wrong KIND of connection — the BYOA fact needs a connection to
|
|
775
|
+
discover — so the file is restored to exactly what it was before the
|
|
776
|
+
refusal is raised. Otherwise the overlay keeps pointing at a connection
|
|
777
|
+
this command just forbade.
|
|
778
|
+
"""
|
|
779
|
+
try:
|
|
780
|
+
rows = _connections.byoa_connections(args.key)
|
|
781
|
+
except _connections.ConnectionError_ as error:
|
|
782
|
+
# A listing that FAILED is not a tenant with no BYOA connections; the
|
|
783
|
+
# remedies are opposite and confusing them sends the reader to create
|
|
784
|
+
# a connection they may already have.
|
|
785
|
+
_restore_bindings(args)
|
|
786
|
+
sys.exit(
|
|
787
|
+
f"prepare-connector: this event object requires a BYOA "
|
|
788
|
+
f"(bring-your-own-app) connection, and the BYOA listing needed to "
|
|
789
|
+
f"check {args.connection_id} against it failed: {error}"
|
|
790
|
+
)
|
|
791
|
+
if args.connection_id in {str(row.get("Id")) for row in rows}:
|
|
792
|
+
return
|
|
793
|
+
_restore_bindings(args)
|
|
794
|
+
sys.exit(
|
|
795
|
+
f"prepare-connector: this event object requires a BYOA "
|
|
796
|
+
f"(bring-your-own-app) connection, and {args.connection_id} is not "
|
|
797
|
+
f"one. A standard connection deploys and then faults at runtime with "
|
|
798
|
+
f"an unrelated-looking webhook error. BYOA {args.key} connections:\n"
|
|
799
|
+
f"{_connections.byoa_listing(rows, args.key)}"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _event_operation(operation: dict) -> str:
|
|
804
|
+
"""The operation key every trigger API takes.
|
|
805
|
+
|
|
806
|
+
NOT `operation.name`: that is the object's CRUD verb (`Create` on outlook
|
|
807
|
+
`email-received`), while the event's own operation is `EMAIL_RECEIVED`,
|
|
808
|
+
which `extract_canonical` stamps as `eventOperation` from the manifest's
|
|
809
|
+
`model.context`. `name` happens to fall back to the same context value
|
|
810
|
+
when the manifest omits `connectorMethodInfo.operation`, so reading it
|
|
811
|
+
works or silently queries the WRONG operation depending on which fallback
|
|
812
|
+
rung a given connector's manifest exercises — and both generation passes
|
|
813
|
+
already key on `eventOperation`.
|
|
814
|
+
"""
|
|
815
|
+
return str(operation.get("eventOperation") or operation.get("name") or "")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _snapshot_bindings(args) -> None:
|
|
819
|
+
"""Remember `bindings.json` byte-for-byte, before stage 0 touches it."""
|
|
820
|
+
path = getattr(args, "bindings", None)
|
|
821
|
+
if not path:
|
|
822
|
+
return
|
|
823
|
+
try:
|
|
824
|
+
with open(path, "rb") as handle:
|
|
825
|
+
args._bindings_before = handle.read()
|
|
826
|
+
except OSError:
|
|
827
|
+
args._bindings_before = None # the file did not exist
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _restore_bindings(args) -> None:
|
|
831
|
+
"""Put `bindings.json` back exactly as `_snapshot_bindings` found it."""
|
|
832
|
+
path = getattr(args, "bindings", None)
|
|
833
|
+
if not path or not hasattr(args, "_bindings_before"):
|
|
834
|
+
return
|
|
835
|
+
before = args._bindings_before
|
|
836
|
+
try:
|
|
837
|
+
if before is None:
|
|
838
|
+
if os.path.exists(path):
|
|
839
|
+
os.remove(path)
|
|
840
|
+
else:
|
|
841
|
+
with open(path, "wb") as handle:
|
|
842
|
+
handle.write(before)
|
|
843
|
+
except OSError as error:
|
|
844
|
+
print(f" WARN: could not restore {path} after refusing the "
|
|
845
|
+
f"connection: {error}", file=sys.stderr)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _prepare_event(args, tmp: str, cache: str) -> bool:
|
|
849
|
+
"""Prepare a connector EVENT — the trigger/wait twin of an activity.
|
|
850
|
+
|
|
851
|
+
Returns False when `<key> <action>` is not an event either, so the caller
|
|
852
|
+
can raise the ordinary unknown-action error. Everything else is handled
|
|
853
|
+
here: the overlay entry is stored under the WAIT form
|
|
854
|
+
(`uipath.connector.event.<key>.<action>`) because the registry only
|
|
855
|
+
enriches the START form and the library derives the start form from its
|
|
856
|
+
wait sibling (`deriveTriggerForm`) — one stored variant serves both node
|
|
857
|
+
roles.
|
|
858
|
+
"""
|
|
859
|
+
trigger_nt = f"uipath.connector.trigger.{args.key}.{args.action}"
|
|
860
|
+
event_nt = f"uipath.connector.event.{args.key}.{args.action}"
|
|
861
|
+
path = gen.registry_get(trigger_nt, cache)
|
|
862
|
+
if not path:
|
|
863
|
+
return False
|
|
864
|
+
entry = gen.extract_canonical(path)
|
|
865
|
+
if not entry:
|
|
866
|
+
return False
|
|
867
|
+
if args.field:
|
|
868
|
+
sys.exit("prepare-connector: -f parent fields apply to activities; "
|
|
869
|
+
"a connector event has none.")
|
|
870
|
+
if args.all_objects:
|
|
871
|
+
sys.exit("prepare-connector: --all-objects applies to Generic "
|
|
872
|
+
"activities; prepare a generic EVENT for one --object.")
|
|
873
|
+
with open(path, encoding="utf-8") as handle:
|
|
874
|
+
raw = json.load(handle)
|
|
875
|
+
node = (raw.get("Data") or {}).get("Node") or raw.get("Node") or {}
|
|
876
|
+
op = entry["operation"]
|
|
877
|
+
operation = _event_operation(op)
|
|
878
|
+
generic = op.get("activityType") == "GenericTrigger"
|
|
879
|
+
|
|
880
|
+
# The objects listing is the generic catalog AND the per-object fact sheet
|
|
881
|
+
# (BYOA, webhook visibility, bucket-scoped Parameters). For a curated
|
|
882
|
+
# event it is advisory — the definition already carries the sections — so
|
|
883
|
+
# a listing failure degrades to a warning there and is fatal only where
|
|
884
|
+
# it is the only source of truth.
|
|
885
|
+
try:
|
|
886
|
+
rows = _events.triggers_objects(args.key, operation, args.connection_id)
|
|
887
|
+
except _events.EventDiscoveryError as error:
|
|
888
|
+
if generic:
|
|
889
|
+
sys.exit(f"prepare-connector: {error}")
|
|
890
|
+
rows = []
|
|
891
|
+
print(f" WARN: {error}", file=sys.stderr)
|
|
892
|
+
|
|
893
|
+
describe = None
|
|
894
|
+
object_row = None
|
|
895
|
+
requested_object = None
|
|
896
|
+
if generic:
|
|
897
|
+
if not args.object:
|
|
898
|
+
sample = ", ".join(
|
|
899
|
+
str(row.get("Name")) for row in rows[:8] if row.get("Name"))
|
|
900
|
+
sys.exit(
|
|
901
|
+
f"prepare-connector: {args.action} is a Generic event — it "
|
|
902
|
+
f"fires for ONE object of the connection. Pass --object "
|
|
903
|
+
f"<name>. This connection's objects include: {sample}"
|
|
904
|
+
+ (" …" if len(rows) > 8 else "")
|
|
905
|
+
)
|
|
906
|
+
# The author's name, not necessarily the API's — same ladder, same
|
|
907
|
+
# refusal semantics as a Generic activity. The listing is live (no
|
|
908
|
+
# CLI-side cache), so there is nothing to refresh on a miss.
|
|
909
|
+
try:
|
|
910
|
+
object_row, note = _objects.match_object(rows, args.object, operation)
|
|
911
|
+
except _objects.ObjectMatchError as error:
|
|
912
|
+
sys.exit(f"prepare-connector: {error}")
|
|
913
|
+
if note:
|
|
914
|
+
print(f" object: {note}")
|
|
915
|
+
requested_object = args.object
|
|
916
|
+
args.object = object_row.get("Name") or args.object
|
|
917
|
+
try:
|
|
918
|
+
describe = _events.triggers_describe(
|
|
919
|
+
args.key, operation, args.object, args.connection_id)
|
|
920
|
+
except _events.EventDiscoveryError as error:
|
|
921
|
+
sys.exit(f"prepare-connector: {error}")
|
|
922
|
+
op["objectName"] = args.object
|
|
923
|
+
op["objectDisplayName"] = str(object_row.get("DisplayName") or "")
|
|
924
|
+
else:
|
|
925
|
+
if args.object:
|
|
926
|
+
sys.exit(
|
|
927
|
+
f"prepare-connector: --object applies to Generic events; "
|
|
928
|
+
f"{args.action}'s object is built in "
|
|
929
|
+
f"({op.get('objectName') or 'none'})."
|
|
930
|
+
)
|
|
931
|
+
baked = str(op.get("objectName") or "")
|
|
932
|
+
object_row = next(
|
|
933
|
+
(row for row in rows if str(row.get("Name")) == baked), None)
|
|
934
|
+
# `triggers describe` is the AUTHORITY for a curated event too, not
|
|
935
|
+
# just a generic one. The registry definition mirrors it and the
|
|
936
|
+
# mirror is lossy: measured on outlook `email-received`, describe
|
|
937
|
+
# returns 15 filter fields and the registry 14 — `isRead` is only in
|
|
938
|
+
# describe, and both agree on all 73 output fields. Reading the mirror
|
|
939
|
+
# would leave `check` REFUSING a filter on `isRead` as an unknown
|
|
940
|
+
# field, which is the one diagnostic here that is an error. Fall back
|
|
941
|
+
# to the definition's own sections when describe cannot answer (a
|
|
942
|
+
# baked object name the trigger catalog does not list) rather than
|
|
943
|
+
# failing the prepare — the mirror is still a usable vocabulary.
|
|
944
|
+
if baked:
|
|
945
|
+
try:
|
|
946
|
+
describe = _events.triggers_describe(
|
|
947
|
+
args.key, operation, baked, args.connection_id)
|
|
948
|
+
except _events.EventDiscoveryError as error:
|
|
949
|
+
print(f" WARN: {error}\n"
|
|
950
|
+
f" falling back to the registry definition's own "
|
|
951
|
+
f"schema sections for {baked}", file=sys.stderr)
|
|
952
|
+
|
|
953
|
+
facts = _events.event_facts(node, object_row)
|
|
954
|
+
if facts["byoaConnection"]:
|
|
955
|
+
_refuse_unless_byoa_connection(args)
|
|
956
|
+
|
|
957
|
+
# One stored shape: the registry definition with the describe sections
|
|
958
|
+
# made true for this object (generic), or with the object row's
|
|
959
|
+
# Parameters unioned in (curated). `Library.eventParameterFields` reads
|
|
960
|
+
# `v1def.eventParameters.fields`, so the overlay v1def IS the store.
|
|
961
|
+
object_parameters = (object_row or {}).get("Parameters")
|
|
962
|
+
if describe is not None:
|
|
963
|
+
node = _events.merge_describe_into_node(node, describe, object_parameters)
|
|
964
|
+
else:
|
|
965
|
+
node = dict(node)
|
|
966
|
+
node["eventParameters"] = {"fields": _events.union_event_parameters(
|
|
967
|
+
(node.get("eventParameters") or {}).get("fields"),
|
|
968
|
+
object_parameters,
|
|
969
|
+
)}
|
|
970
|
+
if (node.get("outputResponseDefinition") or {}).get("fields"):
|
|
971
|
+
entry["outputSchema"] = {
|
|
972
|
+
"fields": node["outputResponseDefinition"]["fields"]}
|
|
973
|
+
node["nodeType"] = event_nt
|
|
974
|
+
entry["nodeType"] = event_nt
|
|
975
|
+
|
|
976
|
+
merged_path = os.path.join(tmp, "event-def.json")
|
|
977
|
+
with open(merged_path, "w", encoding="utf-8") as handle:
|
|
978
|
+
json.dump({"Data": {"Node": node}}, handle)
|
|
979
|
+
|
|
980
|
+
tmplib = os.path.join(tmp, "eventlib")
|
|
981
|
+
gen.write_library(tmplib, [entry], {event_nt: merged_path})
|
|
982
|
+
merge_into(tmplib, args.out)
|
|
983
|
+
|
|
984
|
+
if generic:
|
|
985
|
+
try:
|
|
986
|
+
ledger_path = _objects.record_object_resolution(
|
|
987
|
+
args.out, event_nt, requested_object, object_row, operation)
|
|
988
|
+
except _objects.LedgerError as error:
|
|
989
|
+
sys.exit(f"prepare-connector: {error}")
|
|
990
|
+
print(f" object: {requested_object} -> {args.object} "
|
|
991
|
+
f"recorded in {ledger_path}")
|
|
992
|
+
|
|
993
|
+
if args.resolve:
|
|
994
|
+
_write_resolutions(args, event_nt)
|
|
995
|
+
|
|
996
|
+
_regenerate_descriptors(args)
|
|
997
|
+
|
|
998
|
+
where_fields = (node.get("eventParameters") or {}).get("fields") or []
|
|
999
|
+
filter_fields = (node.get("filterFields") or {}).get("fields") or []
|
|
1000
|
+
mode = facts["eventMode"]
|
|
1001
|
+
print(f"prepare-connector: {event_nt} → {args.out}/ (event overlay, "
|
|
1002
|
+
f"{operation}, {mode}, {len(where_fields)} where-parameter(s), "
|
|
1003
|
+
f"{len(filter_fields)} filter field(s))")
|
|
1004
|
+
sys.stdout.flush()
|
|
1005
|
+
_print_lookup_vocabulary(args, event_nt)
|
|
1006
|
+
if filter_fields:
|
|
1007
|
+
names = ", ".join(str(f.get("name")) for f in filter_fields[:8])
|
|
1008
|
+
print(f"prepare-connector: filterable fields ({len(filter_fields)}): "
|
|
1009
|
+
f"{names}" + (" …" if len(filter_fields) > 8 else ""))
|
|
1010
|
+
if mode == "webhooks":
|
|
1011
|
+
# Registration is an act on the external service — the one step this
|
|
1012
|
+
# loop cannot make deterministic. Hand the author the connector's own
|
|
1013
|
+
# words instead of leaving `uip is webhooks config` to be discovered.
|
|
1014
|
+
print("prepare-connector: note: this event is WEBHOOK mode — it "
|
|
1015
|
+
"cannot be debugged locally (deploy to test), and the webhook "
|
|
1016
|
+
"URL must be registered on the external service"
|
|
1017
|
+
+ (":" if facts["textBlocks"] else
|
|
1018
|
+
" (retrieve it with `uip is webhooks config`)."),
|
|
1019
|
+
file=sys.stderr)
|
|
1020
|
+
for block in facts["textBlocks"]:
|
|
1021
|
+
print(f" {block}", file=sys.stderr)
|
|
1022
|
+
connection_name = getattr(args, "_bound_connection", None) or "<connection>"
|
|
1023
|
+
folder_name = getattr(args, "_bound_folder", None) or "shared"
|
|
1024
|
+
subscription = (
|
|
1025
|
+
f"{{ connector: '{args.key}', event: '{args.action}'"
|
|
1026
|
+
+ (f", object: '{args.object}'" if generic else "")
|
|
1027
|
+
+ f", connection: '{connection_name}', folder: '{folder_name}' }}"
|
|
1028
|
+
)
|
|
1029
|
+
print(f"prepare-connector: next: .trigger(onEvent({subscription})) to "
|
|
1030
|
+
f"start the flow on this event — or .step('…', "
|
|
1031
|
+
f"waitForEvent({subscription})) to pause mid-flow for it")
|
|
1032
|
+
return True
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _overlay_has_generic_expanded(out: str) -> bool:
|
|
1036
|
+
"""Does the overlay hold ANY `GenericExpanded` entry, from any prepare?"""
|
|
1037
|
+
try:
|
|
1038
|
+
with open(os.path.join(out, "index.json"), encoding="utf-8") as handle:
|
|
1039
|
+
entries = json.load(handle).get("entries") or []
|
|
1040
|
+
except (OSError, ValueError):
|
|
1041
|
+
return False
|
|
1042
|
+
return any(e.get("activityType") == "GenericExpanded" for e in entries)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _regenerate_descriptors(args) -> None:
|
|
1046
|
+
"""Rewrite the overlay's typed descriptors from the overlay's own JSON.
|
|
1047
|
+
|
|
1048
|
+
Both prepare paths end here. The EVENT path used not to: it wrote the JSON
|
|
1049
|
+
overlay and returned, so a project whose only prepare was an event got no
|
|
1050
|
+
`.ts` at all, and one that had prepared an action earlier kept that older
|
|
1051
|
+
run's module — generated before the event's parameters were in the overlay,
|
|
1052
|
+
so `where` was `Record<string, string>` and the trigger carried no
|
|
1053
|
+
`lookups`. That is the module `prepare` tells the author to import, and
|
|
1054
|
+
`--refresh-lookups` could not repair it, because nothing on the event path
|
|
1055
|
+
regenerated TypeScript at all.
|
|
1056
|
+
|
|
1057
|
+
`--include-generic-expanded` is decided from the WHOLE overlay index rather
|
|
1058
|
+
than the entries of this run: the generator rewrites every module under
|
|
1059
|
+
`--output`, so reading one run would drop the descriptors an earlier
|
|
1060
|
+
`GenericExpanded` prepare had written.
|
|
1061
|
+
"""
|
|
1062
|
+
if args.json_only:
|
|
1063
|
+
return
|
|
1064
|
+
command = [
|
|
1065
|
+
sys.executable, str(HERE / "generate_connectors_ts.py"),
|
|
1066
|
+
"--library", args.out, "--output", args.out, "--import", args.imp,
|
|
1067
|
+
]
|
|
1068
|
+
if _overlay_has_generic_expanded(args.out):
|
|
1069
|
+
command.append("--include-generic-expanded")
|
|
1070
|
+
subprocess.run(command, check=True)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _write_resolutions(args, node_type: str) -> None:
|
|
1074
|
+
"""Resolve every `--resolve` and merge the answers into the overlay.
|
|
1075
|
+
|
|
1076
|
+
A SEPARATE FILE from the schema overlay, deliberately. A prepared schema is
|
|
1077
|
+
a fact about the connector; a resolution is a fact about one tenant's data,
|
|
1078
|
+
so it ages differently and most consumers will want to gitignore it. Keeping
|
|
1079
|
+
them apart is also what lets `--refresh-lookups` mean something later.
|
|
1080
|
+
|
|
1081
|
+
Existing entries STAND rather than being re-resolved. A resolved id is a
|
|
1082
|
+
snapshot either way; re-resolving on every call would make an unrelated
|
|
1083
|
+
`prepare` silently change an artifact, which is the opposite of what the
|
|
1084
|
+
file is for. Re-resolving is a deliberate act with a visible diff.
|
|
1085
|
+
"""
|
|
1086
|
+
specs = _lookup_specs_from_overlay(args.out, node_type, args.object)
|
|
1087
|
+
requested = [_resolve.parse_resolve_flag(raw) for raw in args.resolve]
|
|
1088
|
+
|
|
1089
|
+
unknown = [f for f, _b, _v in requested if f not in specs]
|
|
1090
|
+
if unknown:
|
|
1091
|
+
known = ", ".join(sorted(specs)) or "(none)"
|
|
1092
|
+
sys.exit(
|
|
1093
|
+
f"prepare-connector: no lookup is declared for "
|
|
1094
|
+
f"{', '.join(repr(f) for f in unknown)} on {node_type}. "
|
|
1095
|
+
f"Fields with a lookup: {known}."
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
ordered = _resolve.order_resolutions(requested, specs)
|
|
1099
|
+
path = os.path.join(args.out, "resolutions.json")
|
|
1100
|
+
# Read and rewrite the WHOLE ledger, not just `resolutions`: the file also
|
|
1101
|
+
# carries the `objects` section (the object decision recorded above), and
|
|
1102
|
+
# rewriting only one section used to silently drop the other. A file that
|
|
1103
|
+
# will not parse is REFUSED rather than reset — same rule as bindings.json.
|
|
1104
|
+
try:
|
|
1105
|
+
ledger = _objects.read_ledger(args.out)
|
|
1106
|
+
except _objects.LedgerError as error:
|
|
1107
|
+
sys.exit(f"prepare-connector: {error}")
|
|
1108
|
+
existing = ledger.get("resolutions") or {}
|
|
1109
|
+
|
|
1110
|
+
resolved_values: dict[str, str] = {}
|
|
1111
|
+
for field, by, value in ordered:
|
|
1112
|
+
# COMPACT SEPARATORS, to match JavaScript exactly. `compile` computes
|
|
1113
|
+
# this key with `JSON.stringify`, which emits no space after a comma;
|
|
1114
|
+
# Python's default emits `", "`. The two encodings of the same tuple are
|
|
1115
|
+
# different strings, so every resolution `prepare` wrote was invisible to
|
|
1116
|
+
# the compile that had just asked for it — the command did the work,
|
|
1117
|
+
# recorded it, and then refused to use it.
|
|
1118
|
+
key = json.dumps([node_type, field, by, value], separators=(",", ":"))
|
|
1119
|
+
if key in existing and not args.refresh_lookups:
|
|
1120
|
+
resolved_values[field] = existing[key]["value"]
|
|
1121
|
+
print(f" {field}: {by}={value} -> {existing[key]['value']} (already resolved)")
|
|
1122
|
+
continue
|
|
1123
|
+
try:
|
|
1124
|
+
got = _resolve.resolve_one(
|
|
1125
|
+
args.key, specs[field], field, by, value,
|
|
1126
|
+
args.connection_id, resolved_values,
|
|
1127
|
+
)
|
|
1128
|
+
except _resolve.ResolutionError as error:
|
|
1129
|
+
sys.exit(f"prepare-connector: {error}")
|
|
1130
|
+
resolved_values[field] = got
|
|
1131
|
+
existing[key] = {
|
|
1132
|
+
"nodeType": node_type, "field": field, "by": by,
|
|
1133
|
+
"match": value, "value": got,
|
|
1134
|
+
}
|
|
1135
|
+
print(f" {field}: {by}={value} -> {got}")
|
|
1136
|
+
|
|
1137
|
+
os.makedirs(args.out, exist_ok=True)
|
|
1138
|
+
ledger["schemaVersion"] = "1"
|
|
1139
|
+
ledger["resolutions"] = existing
|
|
1140
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
1141
|
+
json.dump(ledger, handle, indent=2, sort_keys=True)
|
|
1142
|
+
handle.write("\n")
|
|
1143
|
+
print(f" wrote {len(existing)} resolution(s) to {path}")
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _lookup_specs_from_overlay(
|
|
1147
|
+
out_dir: str, node_type: str, object_name: str | None = None,
|
|
1148
|
+
) -> dict:
|
|
1149
|
+
"""The prepared operation's lookup specs, from the overlay just written.
|
|
1150
|
+
|
|
1151
|
+
Read back from the overlay rather than recomputed, so a resolution is made
|
|
1152
|
+
against the SAME schema the descriptors were generated from — including any
|
|
1153
|
+
connection-resolved field that only exists because this `prepare` ran.
|
|
1154
|
+
"""
|
|
1155
|
+
index_path = os.path.join(out_dir, "index.json")
|
|
1156
|
+
if not os.path.exists(index_path):
|
|
1157
|
+
return {}
|
|
1158
|
+
with open(index_path, encoding="utf-8") as handle:
|
|
1159
|
+
entries = json.load(handle).get("entries", [])
|
|
1160
|
+
invariant = load_invariant_collections(out_dir)
|
|
1161
|
+
for entry in entries:
|
|
1162
|
+
if entry.get("nodeType") != node_type:
|
|
1163
|
+
continue
|
|
1164
|
+
# Generic object variants share a nodeType; without the object filter
|
|
1165
|
+
# the FIRST variant answered for all of them, so a `--resolve` against
|
|
1166
|
+
# the object just prepared read another object's lookup table.
|
|
1167
|
+
if object_name and entry.get("objectName") not in ("", None, object_name):
|
|
1168
|
+
continue
|
|
1169
|
+
entry_path = os.path.join(out_dir, entry["path"])
|
|
1170
|
+
v1_path = entry_path[:-5] + ".v1def.json"
|
|
1171
|
+
if not os.path.exists(entry_path):
|
|
1172
|
+
continue
|
|
1173
|
+
with open(entry_path, encoding="utf-8") as handle:
|
|
1174
|
+
canonical = json.load(handle)
|
|
1175
|
+
v1def = {}
|
|
1176
|
+
if os.path.exists(v1_path):
|
|
1177
|
+
with open(v1_path, encoding="utf-8") as handle:
|
|
1178
|
+
raw = json.load(handle)
|
|
1179
|
+
v1def = raw[0] if isinstance(raw, list) else raw
|
|
1180
|
+
# An EVENT's resolvable fields are its subscription parameters, and
|
|
1181
|
+
# `v1def.eventParameters.fields` is the ONE authority for those — the
|
|
1182
|
+
# same list the SDK, `check` and the descriptor generator read.
|
|
1183
|
+
#
|
|
1184
|
+
# This used to pick the source by testing whether `inputSchema` was
|
|
1185
|
+
# empty, which was right only because the prepare path happens to
|
|
1186
|
+
# leave `inputSchema` as the manifest wrote it while generation
|
|
1187
|
+
# overwrites it. Either path changing flips the heuristic and the
|
|
1188
|
+
# specs come from the manifest's message-BODY fields instead — the
|
|
1189
|
+
# exact wrong list. The node kind is the fact being tested, so test
|
|
1190
|
+
# it.
|
|
1191
|
+
if node_type.startswith(("uipath.connector.event.",
|
|
1192
|
+
"uipath.connector.trigger.")):
|
|
1193
|
+
fields_source = {"inputSchema": {
|
|
1194
|
+
"fields": (v1def.get("eventParameters") or {}).get("fields") or []
|
|
1195
|
+
}}
|
|
1196
|
+
else:
|
|
1197
|
+
fields_source = canonical
|
|
1198
|
+
specs = gen_ts.lookup_specs(fields_source, v1def, invariant)
|
|
1199
|
+
# `filterPattern` is not on the descriptor spec (it is a retrieval
|
|
1200
|
+
# detail, not an authoring one) but the resolver needs it.
|
|
1201
|
+
catalog = {
|
|
1202
|
+
f.get("name"): f["reference"]
|
|
1203
|
+
for f in ((fields_source.get("inputSchema") or {}).get("fields") or [])
|
|
1204
|
+
if isinstance(f.get("reference"), dict)
|
|
1205
|
+
}
|
|
1206
|
+
for field, spec in specs.items():
|
|
1207
|
+
reference = catalog.get(field) or {}
|
|
1208
|
+
if reference.get("filterPattern"):
|
|
1209
|
+
spec["filterPattern"] = reference["filterPattern"]
|
|
1210
|
+
return specs
|
|
1211
|
+
return {}
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
# `import … from "<specifier>"` — the specifier is group 3, quotes preserved.
|
|
1215
|
+
# Deliberately anchored to a real import STATEMENT: the instruction this
|
|
1216
|
+
# replaces became `sed 's|./connectors/|./connectors-local/|'` in practice,
|
|
1217
|
+
# which also rewrites the same text inside comments and inside the imports of
|
|
1218
|
+
# connectors this run did NOT prepare (#704).
|
|
1219
|
+
_IMPORT_RE = re.compile(
|
|
1220
|
+
r"""(^\s*import\s[^;'"]*?\sfrom\s*)(['"])([^'"]+)(\2)""",
|
|
1221
|
+
re.MULTILINE,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
# Where a `.flow.ts` will not be: generated output and installed packages.
|
|
1225
|
+
_SOURCE_SKIP_DIRS = {"node_modules", "connectors", "connectors-local", ".git"}
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _resolves_to(specifier: str, source: Path, target: Path) -> bool:
|
|
1229
|
+
"""Does `specifier`, read from `source`, name the file `target`?
|
|
1230
|
+
|
|
1231
|
+
Compared as RESOLVED paths, not as text, so `./connectors/x.ts` from the
|
|
1232
|
+
project root and `../connectors/x.ts` from a nested source both match the
|
|
1233
|
+
one file — and a same-named connector under a different root does not.
|
|
1234
|
+
"""
|
|
1235
|
+
if not specifier.startswith("."):
|
|
1236
|
+
return False # a bare package, never our overlay
|
|
1237
|
+
try:
|
|
1238
|
+
candidate = (source.parent / specifier).resolve()
|
|
1239
|
+
except (OSError, ValueError):
|
|
1240
|
+
return False
|
|
1241
|
+
return candidate == target
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _sources_importing(baked: Path, root: Path) -> list[Path]:
|
|
1245
|
+
"""Every `*.flow.ts` under `root` whose import resolves to `baked`.
|
|
1246
|
+
|
|
1247
|
+
Selection is by CONTENT rather than "the single flow in this directory":
|
|
1248
|
+
a workspace with three flows, one of which uses this connector, has one
|
|
1249
|
+
correct answer and this finds it.
|
|
1250
|
+
"""
|
|
1251
|
+
found: list[Path] = []
|
|
1252
|
+
for path in sorted(root.rglob("*.flow.ts")):
|
|
1253
|
+
if _SOURCE_SKIP_DIRS & set(path.relative_to(root).parts[:-1]):
|
|
1254
|
+
continue
|
|
1255
|
+
try:
|
|
1256
|
+
text = path.read_text(encoding="utf-8")
|
|
1257
|
+
except (OSError, UnicodeDecodeError):
|
|
1258
|
+
continue
|
|
1259
|
+
if any(_resolves_to(m.group(3), path, baked) for m in _IMPORT_RE.finditer(text)):
|
|
1260
|
+
found.append(path)
|
|
1261
|
+
return found
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _repoint_import(source: Path, baked: Path, prepared: Path) -> str | None:
|
|
1265
|
+
"""Point `source`'s import at the prepared descriptor. The new specifier, or None.
|
|
1266
|
+
|
|
1267
|
+
Only the quoted path changes. The rest of the statement — the named
|
|
1268
|
+
bindings, an `as` alias, the quote style — is carried through untouched,
|
|
1269
|
+
which is the whole difference between this and the find-and-replace it
|
|
1270
|
+
replaces.
|
|
1271
|
+
"""
|
|
1272
|
+
try:
|
|
1273
|
+
text = source.read_text(encoding="utf-8")
|
|
1274
|
+
except (OSError, UnicodeDecodeError):
|
|
1275
|
+
return None
|
|
1276
|
+
specifier = os.path.relpath(prepared, source.parent).replace(os.sep, "/")
|
|
1277
|
+
if not specifier.startswith("."):
|
|
1278
|
+
specifier = f"./{specifier}"
|
|
1279
|
+
changed = False
|
|
1280
|
+
|
|
1281
|
+
def swap(match: "re.Match[str]") -> str:
|
|
1282
|
+
nonlocal changed
|
|
1283
|
+
if not _resolves_to(match.group(3), source, baked):
|
|
1284
|
+
return match.group(0)
|
|
1285
|
+
changed = True
|
|
1286
|
+
return f"{match.group(1)}{match.group(2)}{specifier}{match.group(4)}"
|
|
1287
|
+
|
|
1288
|
+
rewritten = _IMPORT_RE.sub(swap, text)
|
|
1289
|
+
if not changed or rewritten == text:
|
|
1290
|
+
return None
|
|
1291
|
+
source.write_text(rewritten, encoding="utf-8")
|
|
1292
|
+
return specifier
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _repoint_source(args, connector_key: str) -> list[str]:
|
|
1296
|
+
"""Repoint the author's import, or explain why it was left alone.
|
|
1297
|
+
|
|
1298
|
+
`prepare` already writes `bindings.json`, the overlay descriptor and
|
|
1299
|
+
`resolutions.json`; the one thing it did NOT do was update the import
|
|
1300
|
+
naming the descriptor it had just replaced. That left a mechanical edit —
|
|
1301
|
+
one specifier, one file, exactly one right answer — to the caller, who has
|
|
1302
|
+
strictly less information than this command does. Measured cost: an agent
|
|
1303
|
+
reaches for `sed`, which repoints connectors that were never prepared and
|
|
1304
|
+
breaks the flow with `Cannot find module`.
|
|
1305
|
+
|
|
1306
|
+
Ambiguity keeps today's behaviour. Zero candidates or several, and nothing
|
|
1307
|
+
is written — the printed import line still tells the author what to do, so
|
|
1308
|
+
the fallback is exactly the status quo and never worse.
|
|
1309
|
+
"""
|
|
1310
|
+
prepared = (Path(args.out) / f"{connector_key}.ts").resolve()
|
|
1311
|
+
if getattr(args, "no_source_rewrite", False) or not prepared.is_file():
|
|
1312
|
+
return []
|
|
1313
|
+
baked = (Path.cwd() / "connectors" / f"{connector_key}.ts").resolve()
|
|
1314
|
+
|
|
1315
|
+
if getattr(args, "source", None):
|
|
1316
|
+
candidates = [Path(args.source).resolve()]
|
|
1317
|
+
if not candidates[0].is_file():
|
|
1318
|
+
return [f"--source {args.source} does not name a file; "
|
|
1319
|
+
f"left the import alone"]
|
|
1320
|
+
else:
|
|
1321
|
+
candidates = _sources_importing(baked, Path.cwd())
|
|
1322
|
+
|
|
1323
|
+
if not candidates:
|
|
1324
|
+
return []
|
|
1325
|
+
if len(candidates) > 1:
|
|
1326
|
+
names = ", ".join(
|
|
1327
|
+
c.relative_to(Path.cwd()).as_posix() for c in candidates)
|
|
1328
|
+
return [f"{len(candidates)} sources import this connector ({names}); "
|
|
1329
|
+
f"name one with --source to have it repointed"]
|
|
1330
|
+
|
|
1331
|
+
source = candidates[0]
|
|
1332
|
+
specifier = _repoint_import(source, baked, prepared)
|
|
1333
|
+
if specifier is None:
|
|
1334
|
+
return []
|
|
1335
|
+
return [f"repointed {source.relative_to(Path.cwd()).as_posix()} "
|
|
1336
|
+
f"-> {specifier}"]
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _descriptor_usage(out_dir: str, connector_key: str,
|
|
1340
|
+
bound_connection: str = "<binding-name>",
|
|
1341
|
+
bound_folder: str = "<folder-binding-name>") -> list[str]:
|
|
1342
|
+
"""Teach the author how to consume the connection-resolved descriptor.
|
|
1343
|
+
|
|
1344
|
+
The live registry may use a different action id than the baked catalog. The
|
|
1345
|
+
generated descriptor is the authority for that resolved identity, so the
|
|
1346
|
+
success message must carry the author across that boundary instead of
|
|
1347
|
+
leaving them to reverse-engineer index.json.
|
|
1348
|
+
"""
|
|
1349
|
+
source = Path(out_dir) / f"{connector_key}.ts"
|
|
1350
|
+
try:
|
|
1351
|
+
generated = source.read_text()
|
|
1352
|
+
exports = re.findall(
|
|
1353
|
+
r"^export const ([A-Za-z_$][A-Za-z0-9_$]*) = "
|
|
1354
|
+
r"descriptor<([A-Za-z_$][A-Za-z0-9_$]*),",
|
|
1355
|
+
generated,
|
|
1356
|
+
flags=re.MULTILINE,
|
|
1357
|
+
)
|
|
1358
|
+
except OSError:
|
|
1359
|
+
generated = ""
|
|
1360
|
+
exports = []
|
|
1361
|
+
|
|
1362
|
+
import_path = os.path.relpath(source, Path.cwd()).replace(os.sep, "/")
|
|
1363
|
+
if not import_path.startswith("."):
|
|
1364
|
+
import_path = f"./{import_path}"
|
|
1365
|
+
if len(exports) == 1:
|
|
1366
|
+
descriptor_name, inputs_interface = exports[0]
|
|
1367
|
+
required_fields = _required_interface_fields(
|
|
1368
|
+
generated, inputs_interface
|
|
1369
|
+
)
|
|
1370
|
+
if required_fields is None:
|
|
1371
|
+
return [
|
|
1372
|
+
f'open "{source.as_posix()}" and import {descriptor_name}',
|
|
1373
|
+
"read its generated inputs interface before calling connector; "
|
|
1374
|
+
"the required-input skeleton is unavailable",
|
|
1375
|
+
]
|
|
1376
|
+
inputs = "{}"
|
|
1377
|
+
if required_fields:
|
|
1378
|
+
placeholders = ", ".join(
|
|
1379
|
+
f"{name}: /* {field_type}, required */"
|
|
1380
|
+
for name, field_type in required_fields
|
|
1381
|
+
)
|
|
1382
|
+
inputs = f"{{ {placeholders} }}"
|
|
1383
|
+
return [
|
|
1384
|
+
f'import {{ {descriptor_name} }} from "{import_path}";',
|
|
1385
|
+
f'connector({descriptor_name}, {inputs}, '
|
|
1386
|
+
f'{{ connection: "{bound_connection}", folder: "{bound_folder}" }})',
|
|
1387
|
+
"use this descriptor as generated: it carries the resolved live "
|
|
1388
|
+
"operation and object identity even when the baked action id differs",
|
|
1389
|
+
]
|
|
1390
|
+
|
|
1391
|
+
return [
|
|
1392
|
+
f'open "{source.as_posix()}" and import the descriptor for the object you need',
|
|
1393
|
+
"use that descriptor with connector(descriptor, inputs, opts); do not "
|
|
1394
|
+
"reconstruct its live operation/object identity from the baked catalog",
|
|
1395
|
+
]
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def _required_interface_fields(
|
|
1399
|
+
generated: str, interface_name: str
|
|
1400
|
+
) -> list[tuple[str, str]] | None:
|
|
1401
|
+
match = re.search(
|
|
1402
|
+
rf"^export interface {re.escape(interface_name)} \{{(?P<body>.*?)^\}}",
|
|
1403
|
+
generated,
|
|
1404
|
+
flags=re.MULTILINE | re.DOTALL,
|
|
1405
|
+
)
|
|
1406
|
+
if not match:
|
|
1407
|
+
return None
|
|
1408
|
+
|
|
1409
|
+
required: list[tuple[str, str]] = []
|
|
1410
|
+
property_pattern = re.compile(
|
|
1411
|
+
r'^ (?P<name>[A-Za-z_$][A-Za-z0-9_$]*|"(?:\\.|[^"\\])*")'
|
|
1412
|
+
r'(?P<optional>\?)?: (?P<type>.+);$'
|
|
1413
|
+
)
|
|
1414
|
+
for line in match.group("body").splitlines():
|
|
1415
|
+
stripped = line.strip()
|
|
1416
|
+
if not stripped or stripped.startswith(("/**", "*", "*/")):
|
|
1417
|
+
continue
|
|
1418
|
+
field = property_pattern.match(line)
|
|
1419
|
+
if not field:
|
|
1420
|
+
return None
|
|
1421
|
+
if not field.group("optional"):
|
|
1422
|
+
required.append((field.group("name"), _unwrap_value(field.group("type"))))
|
|
1423
|
+
return required
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def _unwrap_value(ts_type: str) -> str:
|
|
1427
|
+
"""`ConnectorValue<string>` -> `string`, for the handoff skeleton.
|
|
1428
|
+
|
|
1429
|
+
The generated property type says what the field ACCEPTS — the literal or an
|
|
1430
|
+
expression — which is the right thing for the compiler and the wrong thing
|
|
1431
|
+
for a one-line "what goes here" hint. An author reading
|
|
1432
|
+
`/* ConnectorValue<string>, required */` learns less than one reading
|
|
1433
|
+
`/* string, required */`, and the wrapper is not something they type.
|
|
1434
|
+
"""
|
|
1435
|
+
match = re.fullmatch(r"Connector(?:Lookup)?Value<(.+)>", ts_type.strip())
|
|
1436
|
+
return match.group(1) if match else ts_type
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _count_input_fields(
|
|
1440
|
+
out_dir: str, node_type: str, object_name: str | None = None
|
|
1441
|
+
) -> int | None:
|
|
1442
|
+
"""Input-field count across matching overlay variants."""
|
|
1443
|
+
try:
|
|
1444
|
+
with open(os.path.join(out_dir, "index.json")) as index_file:
|
|
1445
|
+
idx = json.load(index_file)
|
|
1446
|
+
entries = idx["entries"]
|
|
1447
|
+
if not isinstance(entries, list):
|
|
1448
|
+
return None
|
|
1449
|
+
total = 0
|
|
1450
|
+
matched = False
|
|
1451
|
+
for index_entry in entries:
|
|
1452
|
+
if index_entry["nodeType"] != node_type:
|
|
1453
|
+
continue
|
|
1454
|
+
if object_name is not None and index_entry.get("objectName") != object_name:
|
|
1455
|
+
continue
|
|
1456
|
+
matched = True
|
|
1457
|
+
with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
|
|
1458
|
+
descriptor = json.load(entry_file)
|
|
1459
|
+
if not isinstance(descriptor, dict):
|
|
1460
|
+
return None
|
|
1461
|
+
input_schema = descriptor.get("inputSchema")
|
|
1462
|
+
if not isinstance(input_schema, dict):
|
|
1463
|
+
return None
|
|
1464
|
+
fields = input_schema.get("fields")
|
|
1465
|
+
if not isinstance(fields, list):
|
|
1466
|
+
return None
|
|
1467
|
+
total += len(fields)
|
|
1468
|
+
return total if matched else None
|
|
1469
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError):
|
|
1470
|
+
return None
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _declared_parents(registry_path: str) -> list[str]:
|
|
1474
|
+
"""Parent-input names the registry declares through api-type ObjectActions.
|
|
1475
|
+
|
|
1476
|
+
The second way an operation says "my fields depend on a value": not a
|
|
1477
|
+
`{placeholder}` in a reference path (Jira), but a design-time action whose
|
|
1478
|
+
rules name the inputs it reads (Data Fabric `create-entity-record` declares
|
|
1479
|
+
`GenerateSchema` over `entityName`). `_object_action_specs` already reads
|
|
1480
|
+
them for the replay request; this is the same list, flattened and in
|
|
1481
|
+
declaration order, for the no-`-f` note.
|
|
1482
|
+
"""
|
|
1483
|
+
names: list[str] = []
|
|
1484
|
+
for _action, refs in _object_action_specs(registry_path):
|
|
1485
|
+
for ref in refs:
|
|
1486
|
+
if ref not in names:
|
|
1487
|
+
names.append(ref)
|
|
1488
|
+
return names
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _schema_action_parents(
|
|
1492
|
+
out_dir: str, node_type: str, object_name: str | None = None
|
|
1493
|
+
) -> list[str]:
|
|
1494
|
+
"""Parent inputs the PREPARED operation's own schema action names.
|
|
1495
|
+
|
|
1496
|
+
The third and most direct way an operation says "my fields depend on a
|
|
1497
|
+
value", and the only one that is a PRECONDITION rather than a symptom: the
|
|
1498
|
+
descriptor just written carries `operation.schemaAction.parents`, so with
|
|
1499
|
+
any of those missing from `-f` the action cannot have run, whatever the
|
|
1500
|
+
describe returned. `_parent_fields` and `_declared_parents` both read the
|
|
1501
|
+
result or the registry instead, and each has a shape it cannot see:
|
|
1502
|
+
|
|
1503
|
+
- `_parent_fields` asks "is EVERY prepared field a parent?". Jira's
|
|
1504
|
+
`create-issue` comes back parent-only PLUS `key` (its own issue key), so
|
|
1505
|
+
that test reads an unresolved schema as resolved and says nothing while
|
|
1506
|
+
`compile` is about to refuse `summary` as an unknown input — measured on
|
|
1507
|
+
a real run that then fell back to the generic `insert-record`.
|
|
1508
|
+
- `_declared_parents` reads the registry's api-type ObjectActions, which is
|
|
1509
|
+
empty for that operation.
|
|
1510
|
+
|
|
1511
|
+
Order is the declaration's, which is also the resolution order (a Jira
|
|
1512
|
+
issue-type id is valid only inside its project).
|
|
1513
|
+
"""
|
|
1514
|
+
names: list[str] = []
|
|
1515
|
+
try:
|
|
1516
|
+
with open(os.path.join(out_dir, "index.json")) as index_file:
|
|
1517
|
+
entries = json.load(index_file)["entries"]
|
|
1518
|
+
for index_entry in entries:
|
|
1519
|
+
if index_entry["nodeType"] != node_type:
|
|
1520
|
+
continue
|
|
1521
|
+
if object_name is not None and index_entry.get("objectName") != object_name:
|
|
1522
|
+
continue
|
|
1523
|
+
with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
|
|
1524
|
+
descriptor = json.load(entry_file)
|
|
1525
|
+
action = ((descriptor.get("operation") or {}).get("schemaAction")) or {}
|
|
1526
|
+
for name in action.get("parents") or []:
|
|
1527
|
+
if name and name not in names:
|
|
1528
|
+
names.append(name)
|
|
1529
|
+
except (OSError, KeyError, TypeError, ValueError):
|
|
1530
|
+
return []
|
|
1531
|
+
return names
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _keep_prepared_schema(
|
|
1535
|
+
args, node_type: str, staged: str, parent_fields: dict[str, str],
|
|
1536
|
+
) -> tuple[int, int] | None:
|
|
1537
|
+
"""`(kept, discarded)` when this run must not overwrite the overlay, else `None`.
|
|
1538
|
+
|
|
1539
|
+
`--resolve` records a lookup; `-f` supplies the value a schema action reads.
|
|
1540
|
+
A run carrying only the former cannot have run the action, so what it has to
|
|
1541
|
+
write is the UNRESOLVED entry — and `merge_into` replaces by variant key, so
|
|
1542
|
+
it lands on top of a schema an earlier `-f` run resolved. Measured on Jira
|
|
1543
|
+
`create-issue`: 21 fields down to 3, taking the live-only `fields.reporter.id`
|
|
1544
|
+
and `fields.summary` out of `required` with it, after which `compile` accepts
|
|
1545
|
+
a flow it had been refusing (#666).
|
|
1546
|
+
|
|
1547
|
+
The loop that provokes it is the documented one. `check` prints
|
|
1548
|
+
`prepare … --resolve <field>:<by>=<value>` for every unresolved lookup, with
|
|
1549
|
+
no `-f`, so an author who prepares with `-f`, authors against the live schema
|
|
1550
|
+
and then does what `check` says destroys the schema they just authored
|
|
1551
|
+
against. #685 made that run say so; it still wrote.
|
|
1552
|
+
|
|
1553
|
+
Three conditions, all necessary:
|
|
1554
|
+
|
|
1555
|
+
* the EXISTING overlay declares `schemaAction.parents` — read from
|
|
1556
|
+
`args.out` before the merge, so this is the entry about to be replaced;
|
|
1557
|
+
* none of those parents arrived as `-f`, which is what makes "the action did
|
|
1558
|
+
not run" a fact about the inputs rather than a guess about the result;
|
|
1559
|
+
* the staged entry has FEWER input fields than the one on disk.
|
|
1560
|
+
|
|
1561
|
+
The third keeps this a strict no-loss rule rather than a blanket skip: equal
|
|
1562
|
+
or richer still merges, so a re-pulled library's fresher entry is never
|
|
1563
|
+
pinned by a stale overlay, and a first `--resolve` on an operation with no
|
|
1564
|
+
overlay writes normally (`_count_input_fields` answers `None`).
|
|
1565
|
+
"""
|
|
1566
|
+
parents = _schema_action_parents(args.out, node_type, args.object)
|
|
1567
|
+
if not parents or any(name in parent_fields for name in parents):
|
|
1568
|
+
return None
|
|
1569
|
+
existing = _count_input_fields(args.out, node_type, args.object)
|
|
1570
|
+
incoming = _count_input_fields(staged, node_type, args.object)
|
|
1571
|
+
if existing is None or incoming is None or existing <= incoming:
|
|
1572
|
+
return None
|
|
1573
|
+
return existing, incoming
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _parent_fields(
|
|
1577
|
+
out_dir: str, node_type: str, object_name: str | None = None
|
|
1578
|
+
) -> tuple[list[str], bool]:
|
|
1579
|
+
"""`(parent field names, whether the prepared schema is parent-ONLY)`.
|
|
1580
|
+
|
|
1581
|
+
A reference `path` carrying a `{placeholder}` is the declaration that an
|
|
1582
|
+
operation is parent-driven: `"/project/{fields.project.key}/issuetypes"` on
|
|
1583
|
+
`fields.issuetype.id` names the field whose value must be resolved first,
|
|
1584
|
+
and so also fixes the resolution ORDER. (Only `path` is scanned;
|
|
1585
|
+
`filterPattern` carries an unrelated `{filter}` template.)
|
|
1586
|
+
|
|
1587
|
+
The parent set is both halves of that relation — the placeholder names AND
|
|
1588
|
+
the fields that declare them — because both must be passed as `-f`.
|
|
1589
|
+
Reporting only the placeholders would name `fields.project.key` and omit
|
|
1590
|
+
`fields.issuetype.id`, the very field that declared it.
|
|
1591
|
+
|
|
1592
|
+
"Parent-only" then means every prepared field is a parent: the describe gave
|
|
1593
|
+
back nothing but the inputs that select the real schema. That is the
|
|
1594
|
+
expected shape with no `-f`, and a silent failure WITH `-f` — values that do
|
|
1595
|
+
not exist on the connection are accepted and resolve to exactly this.
|
|
1596
|
+
A resolved schema keeps its parents (Jira's 19 still include both), so
|
|
1597
|
+
subset-of-parents is the discriminator, not the presence of a placeholder.
|
|
1598
|
+
"""
|
|
1599
|
+
names: list[str] = []
|
|
1600
|
+
placeholders: set[str] = set()
|
|
1601
|
+
declaring: set[str] = set()
|
|
1602
|
+
try:
|
|
1603
|
+
with open(os.path.join(out_dir, "index.json")) as index_file:
|
|
1604
|
+
entries = json.load(index_file)["entries"]
|
|
1605
|
+
for index_entry in entries:
|
|
1606
|
+
if index_entry["nodeType"] != node_type:
|
|
1607
|
+
continue
|
|
1608
|
+
if object_name is not None and index_entry.get("objectName") != object_name:
|
|
1609
|
+
continue
|
|
1610
|
+
with open(os.path.join(out_dir, index_entry["path"])) as entry_file:
|
|
1611
|
+
descriptor = json.load(entry_file)
|
|
1612
|
+
for field in descriptor.get("inputSchema", {}).get("fields", []) or []:
|
|
1613
|
+
name = field.get("name")
|
|
1614
|
+
if name and name not in names:
|
|
1615
|
+
names.append(name)
|
|
1616
|
+
path = ((field.get("reference") or {}).get("path")) or ""
|
|
1617
|
+
found = re.findall(r"\{([^{}]+)\}", path)
|
|
1618
|
+
if found:
|
|
1619
|
+
placeholders.update(found)
|
|
1620
|
+
if name:
|
|
1621
|
+
declaring.add(name)
|
|
1622
|
+
except (OSError, KeyError, TypeError, ValueError):
|
|
1623
|
+
return [], False
|
|
1624
|
+
|
|
1625
|
+
parents = placeholders | declaring
|
|
1626
|
+
if not parents:
|
|
1627
|
+
return [], False
|
|
1628
|
+
ordered = [name for name in names if name in parents]
|
|
1629
|
+
ordered += sorted(parents - set(ordered))
|
|
1630
|
+
return ordered, set(names) <= parents
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def _parse_fields(pairs: list[str]) -> dict[str, str]:
|
|
1634
|
+
"""`['a=1', 'b=2']` → `{'a': '1', 'b': '2'}`, rejecting a missing `=`.
|
|
1635
|
+
|
|
1636
|
+
Values may themselves contain `=` (a Salesforce SOQL body token), so split
|
|
1637
|
+
on the FIRST separator only.
|
|
1638
|
+
"""
|
|
1639
|
+
parsed: dict[str, str] = {}
|
|
1640
|
+
for pair in pairs:
|
|
1641
|
+
name, sep, value = pair.partition("=")
|
|
1642
|
+
if not sep or not name.strip():
|
|
1643
|
+
sys.exit(
|
|
1644
|
+
f"prepare-connector: --field expects NAME=VALUE, got {pair!r}."
|
|
1645
|
+
)
|
|
1646
|
+
parsed[name.strip()] = value
|
|
1647
|
+
return parsed
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _rejected_parent_fields_message(
|
|
1651
|
+
pairs: list[str], failures: list[tuple] | None = None
|
|
1652
|
+
) -> str:
|
|
1653
|
+
"""Diagnose a rejected parent-field set, preserving the CLI's own message.
|
|
1654
|
+
|
|
1655
|
+
An incomplete or invalid set is refused outright — "No api-type ObjectAction
|
|
1656
|
+
matched for fields [...] on operation 'Create'" — which already names the
|
|
1657
|
+
fields it could not match. That message never reaches the caller on its own:
|
|
1658
|
+
`is_describe_operation` maps the failure to `None` and enrichment falls back
|
|
1659
|
+
to the registry-declared fields, so the overlay is written as though nothing
|
|
1660
|
+
went wrong. Fail here, and quote the service.
|
|
1661
|
+
"""
|
|
1662
|
+
given = ", ".join(pairs)
|
|
1663
|
+
detail = ""
|
|
1664
|
+
for failure in failures or []:
|
|
1665
|
+
message = failure[-1]
|
|
1666
|
+
if message:
|
|
1667
|
+
detail = f" The service said: {message}"
|
|
1668
|
+
break
|
|
1669
|
+
return (
|
|
1670
|
+
f"prepare-connector: the parent-field set was rejected: {given}.{detail} "
|
|
1671
|
+
"Re-run with NO -f to list the parent fields this operation declares, "
|
|
1672
|
+
"resolve a value for EVERY one of them, then pass them all together. "
|
|
1673
|
+
"Values are connection- and parent-scoped (a Jira issue-type id is valid "
|
|
1674
|
+
"only inside its project), so they cannot be copied from another "
|
|
1675
|
+
"project, connection, or task."
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def _input_field_note(count: int | None) -> str:
|
|
1680
|
+
return (
|
|
1681
|
+
f"{count} input field(s)"
|
|
1682
|
+
if count is not None
|
|
1683
|
+
else "input fields: <unavailable>"
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
if __name__ == "__main__":
|
|
1688
|
+
main()
|