mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-05 14:54:50 +08:00
566 lines
20 KiB
Python
566 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""Discover which .agent file targets (flows, apex classes, retrievers) exist in a Salesforce org.
|
||
|
||
Reads .agent files to extract target: values (flow://, apex://, retriever://),
|
||
queries the org via sf CLI, and reports found/missing targets with fuzzy suggestions.
|
||
|
||
Usage:
|
||
python3 scripts/discover.py --agent-file path/to/Agent.agent -o OrgAlias
|
||
python3 scripts/discover.py --agent-dir force-app/main/default/aiAuthoringBundles -o OrgAlias
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import difflib
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
|
||
@dataclass
|
||
class Suggestion:
|
||
"""A similar resource found in the org."""
|
||
name: str
|
||
similarity: float # 0.0–1.0
|
||
|
||
|
||
@dataclass
|
||
class TargetStatus:
|
||
"""Status of one .agent target."""
|
||
agent_file: str
|
||
target: str # e.g. "flow://Get_Order_Status"
|
||
target_type: str # "flow", "apex", "retriever"
|
||
target_name: str # "Get_Order_Status"
|
||
found: bool = False
|
||
details: str = ""
|
||
suggestions: list[Suggestion] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class IoMismatch:
|
||
"""A mismatch between expected and actual I/O parameters."""
|
||
target_name: str
|
||
field_name: str
|
||
direction: str # "input" or "output"
|
||
expected_type: str
|
||
actual_type: str
|
||
issue: str # "missing", "type_mismatch", "extra"
|
||
|
||
|
||
@dataclass
|
||
class DiscoveryReport:
|
||
"""Collection of target statuses."""
|
||
targets: list[TargetStatus] = field(default_factory=list)
|
||
io_mismatches: list[IoMismatch] = field(default_factory=list)
|
||
|
||
@property
|
||
def found(self) -> list[TargetStatus]:
|
||
return [t for t in self.targets if t.found]
|
||
|
||
@property
|
||
def missing(self) -> list[TargetStatus]:
|
||
return [t for t in self.targets if not t.found]
|
||
|
||
@property
|
||
def all_found(self) -> bool:
|
||
return all(t.found for t in self.targets)
|
||
|
||
|
||
def extract_targets(agent_file: Path) -> list[tuple[str, str, str]]:
|
||
"""Extract target: values from an .agent file.
|
||
|
||
Returns list of (target_uri, target_type, target_name) tuples.
|
||
e.g. ("flow://Get_Order_Status", "flow", "Get_Order_Status")
|
||
"""
|
||
content = agent_file.read_text(encoding="utf-8")
|
||
targets = []
|
||
# Match target: "flow://Name" or target: "apex://Name" or target: "retriever://Name"
|
||
for match in re.finditer(r'target:\s*"?(flow|apex|retriever)://([^"\s]+)"?', content):
|
||
target_type = match.group(1)
|
||
target_name = match.group(2)
|
||
target_uri = f"{target_type}://{target_name}"
|
||
targets.append((target_uri, target_type, target_name))
|
||
return targets
|
||
|
||
|
||
def extract_actions(agent_file: Path) -> list[dict]:
|
||
"""Extract action definitions from an .agent file for scaffolding.
|
||
|
||
Returns list of dicts with keys: name, target, target_type, target_name, inputs, outputs.
|
||
"""
|
||
content = agent_file.read_text(encoding="utf-8")
|
||
actions = []
|
||
# Simple regex-based extraction of action blocks
|
||
# Matches patterns like:
|
||
# action_name:
|
||
# ...
|
||
# target: "flow://Name"
|
||
current_action = None
|
||
current_inputs = []
|
||
current_outputs = []
|
||
in_inputs = False
|
||
in_outputs = False
|
||
|
||
for line in content.splitlines():
|
||
stripped = line.strip()
|
||
|
||
# Detect target line
|
||
target_match = re.match(r'target:\s*"?(flow|apex|retriever)://([^"\s]+)"?', stripped)
|
||
if target_match and current_action:
|
||
current_action["target_type"] = target_match.group(1)
|
||
current_action["target_name"] = target_match.group(2)
|
||
current_action["target"] = f"{target_match.group(1)}://{target_match.group(2)}"
|
||
# Target terminates input/output collection
|
||
in_inputs = False
|
||
in_outputs = False
|
||
continue
|
||
|
||
# Detect description line (captures action description for classification)
|
||
desc_match = re.match(r'description:\s*"(.+)"', stripped)
|
||
if desc_match and current_action and "description" not in current_action:
|
||
current_action["description"] = desc_match.group(1)
|
||
continue
|
||
|
||
# Detect inputs/outputs sections
|
||
if stripped == "inputs:":
|
||
in_inputs = True
|
||
in_outputs = False
|
||
continue
|
||
elif stripped == "outputs:":
|
||
in_outputs = True
|
||
in_inputs = False
|
||
continue
|
||
|
||
# Detect action start (indented name followed by colon)
|
||
action_match = re.match(r'^(\t{2,3}|\s{6,12})(\w+):\s*$', line)
|
||
if action_match:
|
||
# Save previous action
|
||
if current_action and current_action.get("target"):
|
||
current_action["inputs"] = current_inputs
|
||
current_action["outputs"] = current_outputs
|
||
actions.append(current_action)
|
||
current_action = {"name": action_match.group(2)}
|
||
current_inputs = []
|
||
current_outputs = []
|
||
in_inputs = False
|
||
in_outputs = False
|
||
continue
|
||
|
||
# Collect complex_data_type_name for the last collected param
|
||
cdt_match = re.match(r'complex_data_type_name:\s*"([^"]+)"', stripped)
|
||
if cdt_match:
|
||
last_list = current_inputs if in_inputs else (current_outputs if in_outputs else None)
|
||
if last_list:
|
||
last_list[-1]["complex_data_type_name"] = cdt_match.group(1)
|
||
continue
|
||
|
||
# Collect input/output parameters (match "name: type" on stripped text)
|
||
param_match = re.match(r'^(\w+):\s*(string|number|boolean|date|datetime|id|object)\b', stripped)
|
||
if param_match:
|
||
param = {"name": param_match.group(1), "type": param_match.group(2)}
|
||
if in_inputs:
|
||
current_inputs.append(param)
|
||
elif in_outputs:
|
||
current_outputs.append(param)
|
||
|
||
# Don't forget the last action
|
||
if current_action and current_action.get("target"):
|
||
current_action["inputs"] = current_inputs
|
||
current_action["outputs"] = current_outputs
|
||
actions.append(current_action)
|
||
|
||
return actions
|
||
|
||
|
||
def _query_org(query: str, target_org: str) -> list[dict]:
|
||
"""Run SOQL query via sf CLI."""
|
||
cmd = ["sf", "data", "query", "--query", query, "-o", target_org, "--json"]
|
||
try:
|
||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||
if proc.returncode != 0:
|
||
return []
|
||
data = json.loads(proc.stdout)
|
||
return data.get("result", {}).get("records", [])
|
||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
||
return []
|
||
|
||
|
||
def _check_flows(names: list[str], target_org: str) -> dict[str, bool]:
|
||
"""Check which flows exist in the org."""
|
||
records = _query_org(
|
||
"SELECT ApiName FROM FlowDefinitionView WHERE IsActive = true",
|
||
target_org,
|
||
)
|
||
org_flows = {r["ApiName"] for r in records if "ApiName" in r}
|
||
return {name: name in org_flows for name in names}
|
||
|
||
|
||
def _check_apex(names: list[str], target_org: str) -> dict[str, bool]:
|
||
"""Check which Apex classes exist in the org."""
|
||
records = _query_org(
|
||
"SELECT Name FROM ApexClass WHERE Status = 'Active'",
|
||
target_org,
|
||
)
|
||
org_classes = {r["Name"] for r in records if "Name" in r}
|
||
return {name: name in org_classes for name in names}
|
||
|
||
|
||
def _check_retrievers(names: list[str], target_org: str) -> dict[str, bool]:
|
||
"""Check which retrievers (DataKnowledgeSpace) exist in the org."""
|
||
records = _query_org(
|
||
"SELECT DeveloperName FROM DataKnowledgeSpace",
|
||
target_org,
|
||
)
|
||
org_retrievers = {r["DeveloperName"] for r in records if "DeveloperName" in r}
|
||
return {name: name in org_retrievers for name in names}
|
||
|
||
|
||
def _suggest_similar(name: str, available: list[str], threshold: float = 0.4) -> list[Suggestion]:
|
||
"""Find similar names in the org using fuzzy matching."""
|
||
suggestions = []
|
||
name_lower = name.lower()
|
||
name_tokens = set(re.split(r"[_\s]|(?<=[a-z])(?=[A-Z])", name))
|
||
|
||
for candidate in available:
|
||
# Sequence matching
|
||
seq_score = difflib.SequenceMatcher(None, name_lower, candidate.lower()).ratio()
|
||
|
||
# Jaccard keyword overlap
|
||
cand_tokens = set(re.split(r"[_\s]|(?<=[a-z])(?=[A-Z])", candidate))
|
||
if name_tokens and cand_tokens:
|
||
jaccard = len(name_tokens & cand_tokens) / len(name_tokens | cand_tokens)
|
||
else:
|
||
jaccard = 0.0
|
||
|
||
score = max(seq_score, jaccard)
|
||
if score >= threshold:
|
||
suggestions.append(Suggestion(name=candidate, similarity=round(score, 2)))
|
||
|
||
return sorted(suggestions, key=lambda s: s.similarity, reverse=True)[:3]
|
||
|
||
|
||
def _rest_api_get(path: str, target_org: str) -> dict | None:
|
||
"""Call a Salesforce REST API endpoint via sf CLI."""
|
||
cmd = ["sf", "org", "open", "-o", target_org, "--json", "--url-only"]
|
||
try:
|
||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||
if proc.returncode != 0:
|
||
return None
|
||
data = json.loads(proc.stdout)
|
||
instance_url = data.get("result", {}).get("url", "")
|
||
# Extract base URL (everything before /secur/ or similar)
|
||
import urllib.parse
|
||
parsed = urllib.parse.urlparse(instance_url)
|
||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
||
return None
|
||
|
||
# Use sf api to call REST endpoint
|
||
cmd = ["sf", "api", "request", "rest", path, "-o", target_org, "--json"]
|
||
try:
|
||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||
if proc.returncode != 0:
|
||
return None
|
||
return json.loads(proc.stdout)
|
||
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
||
return None
|
||
|
||
|
||
# Flow type to .agent type mapping for validation
|
||
_FLOW_TYPE_TO_AGENT = {
|
||
"STRING": "string",
|
||
"NUMBER": "number",
|
||
"CURRENCY": "number",
|
||
"BOOLEAN": "boolean",
|
||
"DATE": "date",
|
||
"DATETIME": "datetime",
|
||
}
|
||
|
||
|
||
def validate_action_io(
|
||
target_type: str,
|
||
target_name: str,
|
||
expected_inputs: list[dict],
|
||
expected_outputs: list[dict],
|
||
target_org: str,
|
||
) -> list[IoMismatch]:
|
||
"""Validate that an existing target's I/O matches what the .agent file declares.
|
||
|
||
For flows, queries the Actions REST API to get actual parameter schema.
|
||
For apex, queries the Tooling API for @InvocableVariable fields.
|
||
|
||
Args:
|
||
target_type: "flow" or "apex".
|
||
target_name: The API name of the target.
|
||
expected_inputs: Input params declared in the .agent file.
|
||
expected_outputs: Output params declared in the .agent file.
|
||
target_org: Salesforce org alias.
|
||
|
||
Returns:
|
||
List of IoMismatch entries (empty if everything matches).
|
||
"""
|
||
mismatches = []
|
||
|
||
if target_type == "flow":
|
||
mismatches = _validate_flow_io(target_name, expected_inputs, expected_outputs, target_org)
|
||
elif target_type == "apex":
|
||
mismatches = _validate_apex_io(target_name, expected_inputs, expected_outputs, target_org)
|
||
|
||
return mismatches
|
||
|
||
|
||
def _validate_flow_io(
|
||
flow_name: str,
|
||
expected_inputs: list[dict],
|
||
expected_outputs: list[dict],
|
||
target_org: str,
|
||
) -> list[IoMismatch]:
|
||
"""Validate flow I/O via the custom/flow REST API."""
|
||
mismatches = []
|
||
api_path = f"/services/data/v{66}.0/actions/custom/flow/{flow_name}"
|
||
result = _rest_api_get(api_path, target_org)
|
||
if not result:
|
||
return mismatches
|
||
|
||
# Parse actual inputs/outputs from the REST response
|
||
actual_inputs = {}
|
||
actual_outputs = {}
|
||
for param in result.get("result", result).get("inputs", []):
|
||
name = param.get("name", "")
|
||
ptype = param.get("type", "").upper()
|
||
actual_inputs[name] = _FLOW_TYPE_TO_AGENT.get(ptype, "string")
|
||
|
||
for param in result.get("result", result).get("outputs", []):
|
||
name = param.get("name", "")
|
||
ptype = param.get("type", "").upper()
|
||
actual_outputs[name] = _FLOW_TYPE_TO_AGENT.get(ptype, "string")
|
||
|
||
# Check expected inputs exist
|
||
for inp in expected_inputs:
|
||
name = inp["name"]
|
||
if name not in actual_inputs:
|
||
mismatches.append(IoMismatch(
|
||
target_name=flow_name, field_name=name, direction="input",
|
||
expected_type=inp.get("type", "string"), actual_type="",
|
||
issue="missing",
|
||
))
|
||
elif actual_inputs[name] != inp.get("type", "string"):
|
||
mismatches.append(IoMismatch(
|
||
target_name=flow_name, field_name=name, direction="input",
|
||
expected_type=inp.get("type", "string"), actual_type=actual_inputs[name],
|
||
issue="type_mismatch",
|
||
))
|
||
|
||
# Check expected outputs exist
|
||
for out in expected_outputs:
|
||
name = out["name"]
|
||
if name not in actual_outputs:
|
||
mismatches.append(IoMismatch(
|
||
target_name=flow_name, field_name=name, direction="output",
|
||
expected_type=out.get("type", "string"), actual_type="",
|
||
issue="missing",
|
||
))
|
||
|
||
return mismatches
|
||
|
||
|
||
def _validate_apex_io(
|
||
class_name: str,
|
||
expected_inputs: list[dict],
|
||
expected_outputs: list[dict],
|
||
target_org: str,
|
||
) -> list[IoMismatch]:
|
||
"""Validate Apex I/O by querying the class body for @InvocableVariable fields."""
|
||
mismatches = []
|
||
records = _query_org(
|
||
f"SELECT Body FROM ApexClass WHERE Name = '{class_name}' LIMIT 1",
|
||
target_org,
|
||
)
|
||
if not records:
|
||
return mismatches
|
||
|
||
body = records[0].get("Body", "")
|
||
# Extract @InvocableVariable field names from Request/Response classes
|
||
# Simple regex: find field declarations after @InvocableVariable
|
||
field_pattern = re.compile(r'@InvocableVariable[^;]*\n\s*public\s+\w+\s+(\w+)\s*;')
|
||
actual_fields = set(field_pattern.findall(body))
|
||
|
||
for inp in expected_inputs:
|
||
if inp["name"] not in actual_fields:
|
||
mismatches.append(IoMismatch(
|
||
target_name=class_name, field_name=inp["name"], direction="input",
|
||
expected_type=inp.get("type", "string"), actual_type="",
|
||
issue="missing",
|
||
))
|
||
|
||
for out in expected_outputs:
|
||
if out["name"] not in actual_fields:
|
||
mismatches.append(IoMismatch(
|
||
target_name=class_name, field_name=out["name"], direction="output",
|
||
expected_type=out.get("type", "string"), actual_type="",
|
||
issue="missing",
|
||
))
|
||
|
||
return mismatches
|
||
|
||
|
||
def discover(agent_file: Path, target_org: str, validate_io: bool = False) -> DiscoveryReport:
|
||
"""Run discovery for a single .agent file.
|
||
|
||
Args:
|
||
agent_file: Path to the .agent file.
|
||
target_org: Salesforce org alias.
|
||
validate_io: If True, validate I/O parameters for found targets.
|
||
"""
|
||
report = DiscoveryReport()
|
||
raw_targets = extract_targets(agent_file)
|
||
|
||
if not raw_targets:
|
||
return report
|
||
|
||
# Deduplicate targets (same action can appear in multiple topics)
|
||
seen_uris = set()
|
||
unique_targets = []
|
||
for uri, ttype, tname in raw_targets:
|
||
if uri not in seen_uris:
|
||
seen_uris.add(uri)
|
||
unique_targets.append((uri, ttype, tname))
|
||
raw_targets = unique_targets
|
||
|
||
# Group by type
|
||
by_type: dict[str, list[tuple[str, str]]] = {"flow": [], "apex": [], "retriever": []}
|
||
for uri, ttype, tname in raw_targets:
|
||
by_type.setdefault(ttype, []).append((uri, tname))
|
||
|
||
# Check each type
|
||
checkers = {
|
||
"flow": (_check_flows, "SELECT ApiName FROM FlowDefinitionView WHERE IsActive = true", "ApiName"),
|
||
"apex": (_check_apex, "SELECT Name FROM ApexClass WHERE Status = 'Active'", "Name"),
|
||
"retriever": (_check_retrievers, "SELECT DeveloperName FROM DataKnowledgeSpace", "DeveloperName"),
|
||
}
|
||
|
||
for ttype, targets in by_type.items():
|
||
if not targets:
|
||
continue
|
||
|
||
checker_fn, query, field_name = checkers[ttype]
|
||
names = [t[1] for t in targets]
|
||
found_map = checker_fn(names, target_org)
|
||
|
||
# Get all available resources for fuzzy matching
|
||
all_records = _query_org(query, target_org)
|
||
available = [r[field_name] for r in all_records if field_name in r]
|
||
|
||
for uri, name in targets:
|
||
status = TargetStatus(
|
||
agent_file=str(agent_file),
|
||
target=uri,
|
||
target_type=ttype,
|
||
target_name=name,
|
||
found=found_map.get(name, False),
|
||
)
|
||
if not status.found:
|
||
status.suggestions = _suggest_similar(name, available)
|
||
report.targets.append(status)
|
||
|
||
# Validate I/O for found targets
|
||
if validate_io:
|
||
actions = {a["target_name"]: a for a in extract_actions(agent_file) if a.get("target_name")}
|
||
for target in report.found:
|
||
if target.target_type in ("flow", "apex") and target.target_name in actions:
|
||
action_def = actions[target.target_name]
|
||
mismatches = validate_action_io(
|
||
target.target_type,
|
||
target.target_name,
|
||
action_def.get("inputs", []),
|
||
action_def.get("outputs", []),
|
||
target_org,
|
||
)
|
||
report.io_mismatches.extend(mismatches)
|
||
|
||
return report
|
||
|
||
|
||
def discover_dir(agent_dir: Path, target_org: str, validate_io: bool = False) -> DiscoveryReport:
|
||
"""Run discovery for all .agent files in a directory."""
|
||
combined = DiscoveryReport()
|
||
for agent_file in sorted(agent_dir.rglob("*.agent")):
|
||
sub_report = discover(agent_file, target_org, validate_io=validate_io)
|
||
combined.targets.extend(sub_report.targets)
|
||
return combined
|
||
|
||
|
||
def print_report(report: DiscoveryReport) -> None:
|
||
"""Print a human-readable discovery report."""
|
||
if not report.targets:
|
||
print("No targets found in .agent file(s).")
|
||
return
|
||
|
||
print(f"\n{'=' * 60}")
|
||
print(f"Discovery Report: {len(report.targets)} target(s)")
|
||
print(f"{'=' * 60}")
|
||
|
||
# Found targets
|
||
if report.found:
|
||
print(f"\n✅ Found ({len(report.found)}):")
|
||
for t in report.found:
|
||
print(f" {t.target}")
|
||
|
||
# Missing targets
|
||
if report.missing:
|
||
print(f"\n❌ Missing ({len(report.missing)}):")
|
||
for t in report.missing:
|
||
print(f" {t.target}")
|
||
for s in t.suggestions:
|
||
print(f" 💡 Did you mean: {s.name} ({s.similarity:.0%} match)?")
|
||
|
||
# I/O mismatches
|
||
if report.io_mismatches:
|
||
print(f"\n⚠️ I/O Mismatches ({len(report.io_mismatches)}):")
|
||
for m in report.io_mismatches:
|
||
if m.issue == "missing":
|
||
print(f" {m.target_name}: {m.direction} '{m.field_name}' not found in org target")
|
||
elif m.issue == "type_mismatch":
|
||
print(f" {m.target_name}: {m.direction} '{m.field_name}' type mismatch — expected {m.expected_type}, got {m.actual_type}")
|
||
|
||
print(f"\n{'=' * 60}")
|
||
if report.all_found:
|
||
print("All targets found in org.")
|
||
else:
|
||
print(f"{len(report.missing)} target(s) missing. Run /adlc-scaffold to generate stubs.")
|
||
print()
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Discover .agent file targets in a Salesforce org")
|
||
group = parser.add_mutually_exclusive_group(required=True)
|
||
group.add_argument("--agent-file", type=Path, help="Path to a single .agent file")
|
||
group.add_argument("--agent-dir", type=Path, help="Directory containing .agent files")
|
||
parser.add_argument("-o", "--target-org", required=True, help="Salesforce org alias")
|
||
parser.add_argument("--validate-io", action="store_true", help="Validate I/O parameters for found targets")
|
||
args = parser.parse_args()
|
||
|
||
if args.agent_file:
|
||
if not args.agent_file.exists():
|
||
print(f"Error: {args.agent_file} not found", file=sys.stderr)
|
||
sys.exit(1)
|
||
report = discover(args.agent_file, args.target_org, validate_io=args.validate_io)
|
||
else:
|
||
if not args.agent_dir.exists():
|
||
print(f"Error: {args.agent_dir} not found", file=sys.stderr)
|
||
sys.exit(1)
|
||
report = discover_dir(args.agent_dir, args.target_org, validate_io=args.validate_io)
|
||
|
||
print_report(report)
|
||
|
||
# Exit with non-zero if any targets are missing
|
||
sys.exit(0 if report.all_found else 1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|