mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-02 05:09:27 +08:00
2836 lines
114 KiB
Python
2836 lines
114 KiB
Python
|
|
"""Phase 2 Batch 1: pipeline orchestrator for investigating-agentforce-architecture.
|
|||
|
|
|
|||
|
|
Composes Phase 1 primitives (config, rest_client, sf_cli, soql_loader,
|
|||
|
|
probe_channels, fetch_soql, parallel_retrieve, parse_wave, finalize) into
|
|||
|
|
the 13-phase flow defined in the plan.
|
|||
|
|
|
|||
|
|
NOT a credential holder. Credentials live in a single mutable cell
|
|||
|
|
(`_creds_cell`) that both `creds_provider()` and `refresh_fn()` close over.
|
|||
|
|
Every SOQL call uses `creds_provider` so `retry_on_401`'s refresh path
|
|||
|
|
can deliver fresh creds into the retry — storing the URL/token
|
|||
|
|
in function args would defeat the refresh entirely.
|
|||
|
|
|
|||
|
|
Phase ordering:
|
|||
|
|
1 parse args (Bash harness does phases 1-3 today;
|
|||
|
|
2 resolve creds (sf org display) P2 Batch 1 runs them in-process too)
|
|||
|
|
3 probe channels
|
|||
|
|
4 resolve bot + version
|
|||
|
|
5 cache check
|
|||
|
|
6 Wave A (7 parallel Tooling queries, DAG-ordered)
|
|||
|
|
7 join Wave A → _bundle_parsed.json-shaped dict
|
|||
|
|
8 Wave B (Flow + Apex bodies, parallel)
|
|||
|
|
9 parse_wave -> declared_action_tree.json
|
|||
|
|
10 render architecture.md (Batch 2 — stubbed)
|
|||
|
|
11 finalize (atomic writes + manifest)
|
|||
|
|
12 emit RESULT
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
from functools import partial
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Callable, Tuple
|
|||
|
|
|
|||
|
|
# Phase 2 Batch 1: all cross-module wiring routes through config so a future
|
|||
|
|
# relocation of DATA_ROOT / CACHE_ROOT only touches one file.
|
|||
|
|
from config import (
|
|||
|
|
CACHE_TTL_DAYS,
|
|||
|
|
DATA_ROOT,
|
|||
|
|
SCHEMA_VERSION,
|
|||
|
|
build_agent_cache_dir,
|
|||
|
|
build_agent_data_dir,
|
|||
|
|
)
|
|||
|
|
from rest_client import RestClientError, redact_error
|
|||
|
|
from sf_cli import AuthRequired, SfCliError, run_sf
|
|||
|
|
from soql_loader import SoqlParamError
|
|||
|
|
|
|||
|
|
# Phase 2 Batch 1 imports — functional pipeline primitives.
|
|||
|
|
from fetch_soql import (
|
|||
|
|
fetch_apex_bodies_by_ids,
|
|||
|
|
fetch_apex_bodies_by_names,
|
|||
|
|
fetch_bot_definition_details,
|
|||
|
|
fetch_bot_versions,
|
|||
|
|
fetch_flow_definition_by_ids,
|
|||
|
|
fetch_flow_definition_ids_by_names,
|
|||
|
|
fetch_flow_metadata,
|
|||
|
|
fetch_functions_by_plugins,
|
|||
|
|
fetch_planner_attrs,
|
|||
|
|
fetch_planner_bundle_functions,
|
|||
|
|
fetch_planner_definition,
|
|||
|
|
fetch_plugin_functions,
|
|||
|
|
fetch_plugin_instructions,
|
|||
|
|
fetch_plugins_by_planner,
|
|||
|
|
)
|
|||
|
|
from metadata_listing import (
|
|||
|
|
list_prompt_template_metadata,
|
|||
|
|
retrieve_prompt_templates,
|
|||
|
|
)
|
|||
|
|
from parallel_retrieve import fetch_bodies_parallel
|
|||
|
|
from parse_bundle import classify_generation
|
|||
|
|
from probe_channels import probe_channels
|
|||
|
|
from resolve_invocation_target import looks_like_sf_id, resolve_or_unresolved
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# CLI
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
|
|
p = argparse.ArgumentParser(prog="architecture")
|
|||
|
|
p.add_argument("--org-alias", required=True)
|
|||
|
|
p.add_argument("--agent", required=True, dest="agent_api_name")
|
|||
|
|
p.add_argument("--version", dest="agent_version")
|
|||
|
|
p.add_argument("--force", action="store_true", dest="force_refresh")
|
|||
|
|
# --reprobe forces a fresh channel-probe run on pipeline start.
|
|||
|
|
p.add_argument("--reprobe", action="store_true")
|
|||
|
|
p.add_argument("--parallelism", type=int, default=5)
|
|||
|
|
p.add_argument("--max-mermaid-nodes", type=int, default=80)
|
|||
|
|
p.add_argument("--work-dir", required=True)
|
|||
|
|
# Runtime-agnostic path overrides. Defaults are
|
|||
|
|
# ~/.vibe/{data,cache}/investigating-agentforce-architecture/.
|
|||
|
|
# Other runtimes (AFV OOTB, Codex, Cursor, OpenCode) pass these to land
|
|||
|
|
# artifacts under their own distribution layout.
|
|||
|
|
p.add_argument(
|
|||
|
|
"--data-dir",
|
|||
|
|
type=Path,
|
|||
|
|
default=None,
|
|||
|
|
help="Override data root (default: ~/.vibe/data/investigating-agentforce-architecture).",
|
|||
|
|
)
|
|||
|
|
p.add_argument(
|
|||
|
|
"--cache-dir",
|
|||
|
|
type=Path,
|
|||
|
|
default=None,
|
|||
|
|
help="Override cache root (default: ~/.vibe/cache/investigating-agentforce-architecture).",
|
|||
|
|
)
|
|||
|
|
return p.parse_args(argv)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Three-level rebind for --data-dir / --cache-dir overrides.
|
|||
|
|
#
|
|||
|
|
# Python's `from X import Y` snapshots Y into the importer's local namespace.
|
|||
|
|
# Mutating X.Y later does NOT update the importer's local binding. main.py
|
|||
|
|
# does `from config import DATA_ROOT, ...` at module top, so without a
|
|||
|
|
# global rebind, line 2221's `str(DATA_ROOT)` would write the default path
|
|||
|
|
# even when --data-dir was passed.
|
|||
|
|
#
|
|||
|
|
# Levels rebound:
|
|||
|
|
# 1. paths.DATA_ROOT (the source of truth)
|
|||
|
|
# 2. config.DATA_ROOT (the re-export)
|
|||
|
|
# 3. main.DATA_ROOT (THIS module's local snapshot — via global)
|
|||
|
|
#
|
|||
|
|
# Plus: config.PROBE_CACHE_ROOT (derived from CACHE_ROOT at import time).
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _apply_path_overrides(args: argparse.Namespace) -> None:
|
|||
|
|
"""Apply --data-dir / --cache-dir overrides across all 3 namespace levels.
|
|||
|
|
|
|||
|
|
Must be called BEFORE any pipeline code reads DATA_ROOT or CACHE_ROOT.
|
|||
|
|
"""
|
|||
|
|
if not (args.data_dir or args.cache_dir):
|
|||
|
|
return
|
|||
|
|
global DATA_ROOT # rebind level 1: this module
|
|||
|
|
from _shared import runtime, paths
|
|||
|
|
import config
|
|||
|
|
|
|||
|
|
skill_name = "investigating-agentforce-architecture"
|
|||
|
|
if args.data_dir:
|
|||
|
|
runtime.set_data_root_override(args.data_dir)
|
|||
|
|
new_data = runtime.resolve_data_root(skill_name)
|
|||
|
|
paths.DATA_ROOT = new_data # level 2: paths module
|
|||
|
|
config.DATA_ROOT = new_data # level 3: config module re-export
|
|||
|
|
DATA_ROOT = new_data # level 1: main.py local
|
|||
|
|
if args.cache_dir:
|
|||
|
|
runtime.set_cache_root_override(args.cache_dir)
|
|||
|
|
new_cache = runtime.resolve_cache_root(skill_name)
|
|||
|
|
paths.CACHE_ROOT = new_cache
|
|||
|
|
config.CACHE_ROOT = new_cache
|
|||
|
|
config.PROBE_CACHE_ROOT = new_cache / "_channel_probe"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 2 Batch 1: credentials
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Single mutable cell read by `creds_provider`. `refresh_fn` rewrites the
|
|||
|
|
# cell. `retry_on_401` in rest_client invokes refresh_fn for side effect;
|
|||
|
|
# the next invocation of creds_provider picks up the new tuple. See
|
|||
|
|
# rest_client
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_creds_plumbing(
|
|||
|
|
initial_creds: Tuple[str, str],
|
|||
|
|
resolve_creds: Callable[[], Tuple[str, str]],
|
|||
|
|
*,
|
|||
|
|
dedupe_window_s: float = 1.0,
|
|||
|
|
) -> Tuple[
|
|||
|
|
Callable[[], Tuple[str, str]],
|
|||
|
|
Callable[[], Tuple[str, str]],
|
|||
|
|
"list[Tuple[str, str]]",
|
|||
|
|
]:
|
|||
|
|
"""Build the (creds_provider, refresh_fn, creds_cell) triple.
|
|||
|
|
|
|||
|
|
+ `creds_provider` re-reads `creds_cell`
|
|||
|
|
on every call so a refresh actually lands in the NEXT SOQL attempt.
|
|||
|
|
`refresh_fn` is serialized by an internal `threading.Lock` + a
|
|||
|
|
`time.monotonic()` dedupe window — N concurrent 401s against the same
|
|||
|
|
stale token collapse to ONE `resolve_creds()` spawn per window. That
|
|||
|
|
caps real `sf org display` spawns at roughly 1/second regardless of
|
|||
|
|
pool size.
|
|||
|
|
|
|||
|
|
Extracted from `main()` so the closure is testable without threading
|
|||
|
|
a whole pipeline run through argparse + mocks.
|
|||
|
|
|
|||
|
|
Returns (creds_provider, refresh_fn, creds_cell). The cell is
|
|||
|
|
returned so callers/tests can inspect the current tuple directly
|
|||
|
|
(it's the single source of truth the provider reads from).
|
|||
|
|
"""
|
|||
|
|
creds_cell: list[Tuple[str, str]] = [initial_creds]
|
|||
|
|
lock = threading.Lock()
|
|||
|
|
last_refresh_mono = [0.0]
|
|||
|
|
|
|||
|
|
def creds_provider() -> Tuple[str, str]:
|
|||
|
|
return creds_cell[0]
|
|||
|
|
|
|||
|
|
def refresh_fn() -> Tuple[str, str]:
|
|||
|
|
with lock:
|
|||
|
|
now = time.monotonic()
|
|||
|
|
if now - last_refresh_mono[0] < dedupe_window_s:
|
|||
|
|
# Another thread refreshed within the dedupe window — reuse.
|
|||
|
|
return creds_cell[0]
|
|||
|
|
creds_cell[0] = resolve_creds()
|
|||
|
|
last_refresh_mono[0] = now
|
|||
|
|
return creds_cell[0]
|
|||
|
|
|
|||
|
|
return creds_provider, refresh_fn, creds_cell
|
|||
|
|
|
|||
|
|
|
|||
|
|
_REDACTION_MARKER_FRAGMENT = "show-access-token"
|
|||
|
|
"""Substring that uniquely identifies the sf CLI v2 redaction placeholder
|
|||
|
|
(`"[REDACTED] Use 'sf org auth show-access-token' to view"`). We match on
|
|||
|
|
this fragment rather than the full literal because the upstream wording
|
|||
|
|
has shifted between sf CLI builds, but the embedded subcommand name has
|
|||
|
|
been stable since cli#3560 landed."""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_creds(org_alias: str) -> Tuple[str, str]:
|
|||
|
|
"""Resolve (instance_url, access_token) for ``org_alias``.
|
|||
|
|
|
|||
|
|
Two-path strategy per forcedotcom/cli#3560 (effective 2026-05-27):
|
|||
|
|
|
|||
|
|
1. **Primary** — call ``sf org display`` for instanceUrl, then
|
|||
|
|
``sf org auth show-access-token`` for the access token. This is
|
|||
|
|
the upstream long-term path; ``SF_TEMP_SHOW_SECRETS`` is
|
|||
|
|
decommissioned in summer 2026.
|
|||
|
|
2. **Fallback** — if the dedicated command isn't shipped in the
|
|||
|
|
installed sf CLI version (older releases), fall back to the
|
|||
|
|
legacy ``sf org display --verbose`` with the
|
|||
|
|
``SF_TEMP_SHOW_SECRETS=true`` env var that ``run_sf`` injects
|
|||
|
|
unconditionally. The fallback is detected by the redaction
|
|||
|
|
placeholder appearing in the ``accessToken`` field — exact match
|
|||
|
|
on the substring ``show-access-token`` (see
|
|||
|
|
``_REDACTION_MARKER_FRAGMENT``).
|
|||
|
|
|
|||
|
|
sf_cli.run_sf already redacts stderr on failure paths. Any
|
|||
|
|
exception surfaces as `AuthRequired` / `SfCliError`; caller routes
|
|||
|
|
through `_emit_fail`.
|
|||
|
|
"""
|
|||
|
|
display_data = run_sf("org_display", ORG_ALIAS=org_alias)
|
|||
|
|
display_result = display_data.get("result") or {}
|
|||
|
|
instance_url = display_result.get("instanceUrl") or ""
|
|||
|
|
if not instance_url:
|
|||
|
|
raise AuthRequired(
|
|||
|
|
"sf org display returned an incomplete payload (missing instanceUrl)"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Primary path: dedicated command. Catches both the unknown-command
|
|||
|
|
# case (older sf CLI) and any AuthRequired surfaced from the recipe.
|
|||
|
|
try:
|
|||
|
|
token_data = run_sf("show_access_token", ORG_ALIAS=org_alias)
|
|||
|
|
except SfCliError:
|
|||
|
|
# Older sf CLI versions emit "is not a sf command" on stderr; the
|
|||
|
|
# recipe surfaces that as SfCliError. Fall back to the
|
|||
|
|
# SF_TEMP_SHOW_SECRETS path via the org_display payload we
|
|||
|
|
# already have.
|
|||
|
|
access_token = display_result.get("accessToken") or ""
|
|||
|
|
else:
|
|||
|
|
token_result = token_data.get("result") or {}
|
|||
|
|
access_token = token_result.get("accessToken") or ""
|
|||
|
|
|
|||
|
|
# Defensive fallback: if the dedicated command silently returned an
|
|||
|
|
# empty token OR sf CLI redacted the org_display token to the
|
|||
|
|
# placeholder, try the org_display payload directly. SF_TEMP_SHOW_SECRETS
|
|||
|
|
# is set unconditionally by run_sf so this works as long as the env
|
|||
|
|
# var is still honoured by the installed sf CLI.
|
|||
|
|
if not access_token or _REDACTION_MARKER_FRAGMENT in access_token:
|
|||
|
|
legacy_token = display_result.get("accessToken") or ""
|
|||
|
|
if legacy_token and _REDACTION_MARKER_FRAGMENT not in legacy_token:
|
|||
|
|
access_token = legacy_token
|
|||
|
|
|
|||
|
|
if not access_token or _REDACTION_MARKER_FRAGMENT in access_token:
|
|||
|
|
raise AuthRequired(
|
|||
|
|
"could not retrieve a usable access token via sf org auth "
|
|||
|
|
"show-access-token (primary) or sf org display (fallback); "
|
|||
|
|
"ensure sf CLI is logged in for this org and either the "
|
|||
|
|
"dedicated command is available or SF_TEMP_SHOW_SECRETS is "
|
|||
|
|
"still honoured."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return instance_url, access_token
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _derive_org_ids(org_alias: str) -> Tuple[str, str, str]:
|
|||
|
|
"""Run `sf org display --json` once more for org_id_15 + org_id_18 +
|
|||
|
|
api_version.
|
|||
|
|
|
|||
|
|
Reused call: the second `sf org display` is cheap and keeps the
|
|||
|
|
creds-resolve + org-ids functions uncoupled. When a run passes both
|
|||
|
|
through together, the overhead is two spawn()s not one — fine given
|
|||
|
|
the whole pipeline is O(seconds).
|
|||
|
|
|
|||
|
|
Returns (org_id_15, org_id_18, api_version) — all strings. api_version
|
|||
|
|
matches `vNN.N` (validated by probe_channels' path builder downstream).
|
|||
|
|
|
|||
|
|
Gap 3 fix (2026-05-05): returns the 18-char id as well so downstream
|
|||
|
|
emit helpers can populate the RESULT-block `ORG_ID_18` field. The
|
|||
|
|
18-char value comes straight from `sf org display`; we do NOT compute
|
|||
|
|
it from the 15-char prefix.
|
|||
|
|
"""
|
|||
|
|
data = run_sf("org_display", ORG_ALIAS=org_alias)
|
|||
|
|
result = data.get("result") or {}
|
|||
|
|
org_id_18 = result.get("id") or ""
|
|||
|
|
# SF org id is 18 chars; the 15-char slice is the stable prefix used
|
|||
|
|
# everywhere in this skill. Fail fast if shorter.
|
|||
|
|
if len(org_id_18) < 15:
|
|||
|
|
raise SfCliError(f"sf org display returned malformed id (len={len(org_id_18)})")
|
|||
|
|
org_id_15 = org_id_18[:15]
|
|||
|
|
api_version_raw = str(result.get("apiVersion") or "")
|
|||
|
|
# apiVersion from sf CLI is "60.0" shape; our validator requires "v60.0".
|
|||
|
|
api_version = api_version_raw if api_version_raw.startswith("v") else f"v{api_version_raw}"
|
|||
|
|
return org_id_15, org_id_18, api_version
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 4: bot resolution (in-process; Data API via fetch_soql)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _resolve_bot(
|
|||
|
|
agent_api_name: str,
|
|||
|
|
explicit_version: str | None,
|
|||
|
|
creds_provider: Callable[[], Tuple[str, str]],
|
|||
|
|
refresh_fn: Callable[[], Tuple[str, str]],
|
|||
|
|
*,
|
|||
|
|
api_version: str,
|
|||
|
|
) -> dict | None:
|
|||
|
|
"""Resolve (bot_id, version, master_label, planner_name, bot_def_details).
|
|||
|
|
|
|||
|
|
Returns a dict with:
|
|||
|
|
bot_id, version, master_label, version_auto_picked (bool),
|
|||
|
|
bot_definition (dict row from BotDefinition details query),
|
|||
|
|
all_versions (list of {version, status}) — retained for error ctx.
|
|||
|
|
|
|||
|
|
Returns None on AGENT_NOT_FOUND. Caller distinguishes by checking the
|
|||
|
|
absence — not by catching an exception — to keep the error-emit path
|
|||
|
|
a flat if-chain.
|
|||
|
|
|
|||
|
|
`api_version` is threaded in from
|
|||
|
|
`_derive_org_ids`. The Data-API helpers require it — see
|
|||
|
|
`rest_client.data_query` for why the old hardcoded `v60.0` was
|
|||
|
|
wrong (real orgs run v66 and expose fields v60 does not).
|
|||
|
|
"""
|
|||
|
|
versions = fetch_bot_versions(
|
|||
|
|
agent_api_name, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
)
|
|||
|
|
if not versions:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# Natural-key sort: v10 > v9 > v2 > v1. Copy the scheme from
|
|||
|
|
# resolve_bot.py so multi-version bots rank consistently across the
|
|||
|
|
# in-process + shell-out entry points.
|
|||
|
|
def _natural_key(v: dict) -> list:
|
|||
|
|
import re
|
|||
|
|
dn = v.get("DeveloperName") or ""
|
|||
|
|
return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", dn)]
|
|||
|
|
|
|||
|
|
versions.sort(key=_natural_key, reverse=True)
|
|||
|
|
|
|||
|
|
picked = None
|
|||
|
|
if explicit_version:
|
|||
|
|
for v in versions:
|
|||
|
|
if v.get("DeveloperName") == explicit_version:
|
|||
|
|
picked = v
|
|||
|
|
break
|
|||
|
|
version_auto_picked = False
|
|||
|
|
else:
|
|||
|
|
for v in versions:
|
|||
|
|
if v.get("Status") == "Active":
|
|||
|
|
picked = v
|
|||
|
|
break
|
|||
|
|
version_auto_picked = True
|
|||
|
|
|
|||
|
|
if not picked:
|
|||
|
|
# Signal "version miss" distinctly from "bot miss". Caller emits
|
|||
|
|
# STATUS=AGENT_VERSION_NOT_FOUND with AVAILABLE_VERSIONS.
|
|||
|
|
return {
|
|||
|
|
"bot_id": "",
|
|||
|
|
"version": explicit_version or "",
|
|||
|
|
"master_label": "",
|
|||
|
|
"version_auto_picked": False,
|
|||
|
|
"all_versions": [
|
|||
|
|
{
|
|||
|
|
"version": v.get("DeveloperName") or "",
|
|||
|
|
"status": v.get("Status") or "",
|
|||
|
|
}
|
|||
|
|
for v in versions
|
|||
|
|
],
|
|||
|
|
"_version_not_found": True,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bot_id = picked.get("BotDefinitionId") or ""
|
|||
|
|
bd_node = picked.get("BotDefinition") or {}
|
|||
|
|
master_label = bd_node.get("MasterLabel") or ""
|
|||
|
|
chosen_version = picked.get("DeveloperName") or ""
|
|||
|
|
|
|||
|
|
# Phase 2 Batch 1: pull BotDefinition metadata once for the tree's
|
|||
|
|
# `agent` block. Non-fatal if the second query fails — tree falls
|
|||
|
|
# back to defaults.
|
|||
|
|
try:
|
|||
|
|
bot_def = fetch_bot_definition_details(
|
|||
|
|
agent_api_name, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError):
|
|||
|
|
bot_def = None
|
|||
|
|
|
|||
|
|
# Planner-name resolution moved to Wave A — `fetch_planner_definition`
|
|||
|
|
# now takes (agent_api_name, chosen_version) and performs a chain-LIKE
|
|||
|
|
# lookup (`<agent>%\_<vN>`). The canonical planner DeveloperName comes
|
|||
|
|
# back on the row and propagates downstream via the bundle join.
|
|||
|
|
return {
|
|||
|
|
"bot_id": bot_id,
|
|||
|
|
"version": chosen_version,
|
|||
|
|
"master_label": master_label,
|
|||
|
|
"version_auto_picked": version_auto_picked,
|
|||
|
|
"bot_definition": bot_def or {},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 5: cache check
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _cache_is_fresh(cache_dir: Path) -> dict | None:
|
|||
|
|
"""Return the manifest dict if the cache is hot + fresh, else None.
|
|||
|
|
|
|||
|
|
Checks: manifest exists, schema_version matches, TTL not expired,
|
|||
|
|
data_path is a real file. Any failure degrades to `None` so the
|
|||
|
|
caller falls through to the pipeline-run path.
|
|||
|
|
|
|||
|
|
Mirrors cache_check.py's shell-level logic; duplicated in-process so
|
|||
|
|
main.py can branch without shelling out.
|
|||
|
|
"""
|
|||
|
|
import datetime as dt
|
|||
|
|
|
|||
|
|
manifest_path = cache_dir / "manifest.json"
|
|||
|
|
if not manifest_path.is_file():
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
manifest = json.loads(manifest_path.read_text())
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if str(manifest.get("schema_version") or "0") != SCHEMA_VERSION:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
data_path_s = manifest.get("data_path") or ""
|
|||
|
|
if not data_path_s or not Path(data_path_s).is_file():
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
built = dt.datetime.fromisoformat(
|
|||
|
|
(manifest.get("built_at_utc") or "").replace("Z", "+00:00")
|
|||
|
|
)
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
age_days = (dt.datetime.now(dt.timezone.utc) - built).days
|
|||
|
|
ttl = int(manifest.get("ttl_days") or CACHE_TTL_DAYS)
|
|||
|
|
if age_days > ttl:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
return manifest
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 6: Wave A
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_wave_a(
|
|||
|
|
agent_api_name: str,
|
|||
|
|
version: str | None,
|
|||
|
|
creds_provider: Callable[[], Tuple[str, str]],
|
|||
|
|
refresh_fn: Callable[[], Tuple[str, str]],
|
|||
|
|
*,
|
|||
|
|
api_version: str,
|
|||
|
|
parallelism: int = 5,
|
|||
|
|
) -> dict | None:
|
|||
|
|
"""Run the 7-query GenAi normalized DAG in layered parallel steps.
|
|||
|
|
|
|||
|
|
A1 resolves the live `GenAiPlannerDefinition` from (agent_api_name,
|
|||
|
|
version) via a chain-LIKE lookup — Agentforce's accretive planner
|
|||
|
|
naming (v1=`<Agent>`, v2=`<Agent>_v2`, v3=`<Agent>_v2_v3`, ...) means
|
|||
|
|
(agent, version) alone is the only reliable key. The resolved row's
|
|||
|
|
`DeveloperName` is the canonical planner_name that propagates
|
|||
|
|
downstream via the bundle join in `_join_wave_a_to_bundle`.
|
|||
|
|
|
|||
|
|
Returns a dict:
|
|||
|
|
{
|
|||
|
|
"planner": {...},
|
|||
|
|
"plugins": [...], # A2
|
|||
|
|
"bundle_functions_join": [...], # A3
|
|||
|
|
"functions": [...], # A4
|
|||
|
|
"instructions": [...], # A5
|
|||
|
|
"plugin_functions_join": [...], # A6
|
|||
|
|
"attrs": [...], # A7
|
|||
|
|
"unresolved": [...], # per-channel A2..A6 failures
|
|||
|
|
}
|
|||
|
|
Returns None if A1 yields no planner row — caller emits a clean
|
|||
|
|
error instead of failing later in the join.
|
|||
|
|
|
|||
|
|
Failures:
|
|||
|
|
* RestClientError / HTTPError on A1 → propagates. No graceful path
|
|||
|
|
when the planner itself is unreachable.
|
|||
|
|
* A2..A6 failures: one failure in a parallel layer doesn't abort
|
|||
|
|
the layer (fetch_bodies_parallel's contract). The failed channel
|
|||
|
|
yields an empty data list AND an entry in `unresolved` keyed by
|
|||
|
|
channel with reason `wave-a-<channel>-failed:<redacted>`. The
|
|||
|
|
caller folds these into `tree["_unresolved"]` so STATUS flips
|
|||
|
|
to PARTIAL_OK.
|
|||
|
|
"""
|
|||
|
|
# A1 — scalar, no parallelism. api_version threads through
|
|||
|
|
# every fetcher; see module docstring + fetch_soql.py.
|
|||
|
|
planner = fetch_planner_definition(
|
|||
|
|
agent_api_name, version, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
)
|
|||
|
|
if not planner:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
planner_id = planner.get("Id")
|
|||
|
|
if not planner_id:
|
|||
|
|
# Planner row missing Id — malformed response. Treat as miss.
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
unresolved: list[dict] = []
|
|||
|
|
|
|||
|
|
# Layer 2: A2 + A3 in parallel
|
|||
|
|
layer2 = fetch_bodies_parallel(
|
|||
|
|
[
|
|||
|
|
partial(
|
|||
|
|
fetch_plugins_by_planner,
|
|||
|
|
planner_id, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
),
|
|||
|
|
partial(
|
|||
|
|
fetch_planner_bundle_functions,
|
|||
|
|
planner_id, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
max_workers=parallelism,
|
|||
|
|
)
|
|||
|
|
plugins = layer2[0][1] if layer2[0][0] else []
|
|||
|
|
bundle_functions_join = layer2[1][1] if layer2[1][0] else []
|
|||
|
|
for channel, (ok, r) in zip(("plugins", "bundle-functions"), layer2):
|
|||
|
|
if not ok:
|
|||
|
|
entry = {
|
|||
|
|
"kind": "WAVE_A",
|
|||
|
|
"channel": channel,
|
|||
|
|
"reason": f"wave-a-{channel}-failed:{redact_error(r)}",
|
|||
|
|
}
|
|||
|
|
preview = getattr(r, "_response_body_preview", None)
|
|||
|
|
if preview:
|
|||
|
|
entry["response_body_preview"] = preview
|
|||
|
|
unresolved.append(entry)
|
|||
|
|
|
|||
|
|
plugin_ids = [p.get("Id") for p in plugins if p.get("Id")]
|
|||
|
|
|
|||
|
|
# Layer 3: A4 + A5 + A6 in parallel
|
|||
|
|
layer3 = fetch_bodies_parallel(
|
|||
|
|
[
|
|||
|
|
partial(
|
|||
|
|
fetch_functions_by_plugins,
|
|||
|
|
plugin_ids, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
),
|
|||
|
|
partial(
|
|||
|
|
fetch_plugin_instructions,
|
|||
|
|
plugin_ids, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
),
|
|||
|
|
partial(
|
|||
|
|
fetch_plugin_functions,
|
|||
|
|
plugin_ids, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
max_workers=parallelism,
|
|||
|
|
)
|
|||
|
|
functions = layer3[0][1] if layer3[0][0] else []
|
|||
|
|
instructions = layer3[1][1] if layer3[1][0] else []
|
|||
|
|
plugin_functions_join = layer3[2][1] if layer3[2][0] else []
|
|||
|
|
for channel, (ok, r) in zip(
|
|||
|
|
("functions", "instructions", "plugin-functions"), layer3,
|
|||
|
|
):
|
|||
|
|
if not ok:
|
|||
|
|
entry = {
|
|||
|
|
"kind": "WAVE_A",
|
|||
|
|
"channel": channel,
|
|||
|
|
"reason": f"wave-a-{channel}-failed:{redact_error(r)}",
|
|||
|
|
}
|
|||
|
|
preview = getattr(r, "_response_body_preview", None)
|
|||
|
|
if preview:
|
|||
|
|
entry["response_body_preview"] = preview
|
|||
|
|
unresolved.append(entry)
|
|||
|
|
|
|||
|
|
# Layer 4: A7 alone, parent_ids = function_ids. Planner-level attrs
|
|||
|
|
# returned 0 rows live (a planner never carries direct attrs) so the
|
|||
|
|
# prior `function_ids ∪ {planner_id}` widened the IN-list for no
|
|||
|
|
# payoff. Keep the list minimal; dedupe defensively in case a
|
|||
|
|
# function Id repeats across bundles.
|
|||
|
|
parent_ids = list(dict.fromkeys(
|
|||
|
|
f.get("Id") for f in functions if f.get("Id")
|
|||
|
|
))
|
|||
|
|
try:
|
|||
|
|
attrs = fetch_planner_attrs(
|
|||
|
|
parent_ids, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError):
|
|||
|
|
attrs = []
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"planner": planner,
|
|||
|
|
"plugins": plugins,
|
|||
|
|
"bundle_functions_join": bundle_functions_join,
|
|||
|
|
"functions": functions,
|
|||
|
|
"instructions": instructions,
|
|||
|
|
"plugin_functions_join": plugin_functions_join,
|
|||
|
|
"attrs": attrs,
|
|||
|
|
"unresolved": unresolved,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 7: join Wave A → _bundle_parsed.json shape
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _join_wave_a_to_bundle(
|
|||
|
|
bot_info: dict,
|
|||
|
|
wave_a: dict,
|
|||
|
|
) -> dict:
|
|||
|
|
"""Reshape normalized Wave-A rows into the bundle dict parse_wave expects.
|
|||
|
|
|
|||
|
|
`parse_bundle.py` wrote XML-shaped bundles keyed by:
|
|||
|
|
plannerType, plannerName, generation, description, masterLabel,
|
|||
|
|
topics[{name, actions[{name, invocationTarget, invocationTargetType, ...}]}],
|
|||
|
|
plannerActions[{...}]
|
|||
|
|
|
|||
|
|
We reconstruct that shape from the Tooling rows so parse_wave's
|
|||
|
|
`init_tree` + `build_root_children` accept the dict unchanged.
|
|||
|
|
"""
|
|||
|
|
planner = wave_a["planner"]
|
|||
|
|
planner_type = planner.get("PlannerType") or ""
|
|||
|
|
generation = classify_generation(planner_type)
|
|||
|
|
|
|||
|
|
functions_by_id = {f.get("Id"): f for f in wave_a["functions"] if f.get("Id")}
|
|||
|
|
# Plugin join: map plugin_id → ordered function_ids
|
|||
|
|
plugin_fn_map: dict[str, list[str]] = {}
|
|||
|
|
for row in wave_a["plugin_functions_join"]:
|
|||
|
|
pid = row.get("PluginId")
|
|||
|
|
fn_id = row.get("Function")
|
|||
|
|
if pid and fn_id:
|
|||
|
|
plugin_fn_map.setdefault(pid, []).append(fn_id)
|
|||
|
|
|
|||
|
|
def _action_dict(fn: dict) -> dict:
|
|||
|
|
"""Shape a GenAiFunctionDefinition row as a bundle-action dict."""
|
|||
|
|
return {
|
|||
|
|
"name": fn.get("DeveloperName") or fn.get("Id") or "",
|
|||
|
|
"localDeveloperName": fn.get("LocalDeveloperName"),
|
|||
|
|
"masterLabel": fn.get("MasterLabel"),
|
|||
|
|
"description": fn.get("Description"),
|
|||
|
|
"invocationTarget": fn.get("InvocationTarget"),
|
|||
|
|
"invocationTargetType": (fn.get("InvocationTargetType") or "").strip(),
|
|||
|
|
"source": fn.get("Source"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
topics = []
|
|||
|
|
for plugin in wave_a["plugins"]:
|
|||
|
|
pid = plugin.get("Id")
|
|||
|
|
fn_ids = plugin_fn_map.get(pid, [])
|
|||
|
|
actions = [
|
|||
|
|
_action_dict(functions_by_id[fid])
|
|||
|
|
for fid in fn_ids if fid in functions_by_id
|
|||
|
|
]
|
|||
|
|
topics.append({
|
|||
|
|
"name": plugin.get("DeveloperName") or "",
|
|||
|
|
"localDeveloperName": plugin.get("LocalDeveloperName"),
|
|||
|
|
"masterLabel": plugin.get("MasterLabel"),
|
|||
|
|
"description": plugin.get("Description"),
|
|||
|
|
"canEscalate": bool(plugin.get("CanEscalate")),
|
|||
|
|
"pluginType": plugin.get("PluginType"),
|
|||
|
|
"actions": actions,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
# A planner never has direct functions (user invariant, verified
|
|||
|
|
# against my-org-alias). The previous `PlannerId`-leg query leaked
|
|||
|
|
# orphan rows (e.g. `AnswerQuestionsWithKnowledge_*`) as root-level
|
|||
|
|
# children. Key retained for downstream compatibility with callers
|
|||
|
|
# that read `bundle.get("plannerActions", [])` (parse_wave, a few
|
|||
|
|
# _route / _normalize paths); always emits an empty list now.
|
|||
|
|
return {
|
|||
|
|
"plannerType": planner_type,
|
|||
|
|
"plannerName": planner.get("DeveloperName"),
|
|||
|
|
"generation": generation,
|
|||
|
|
"description": planner.get("Description"),
|
|||
|
|
"masterLabel": planner.get("MasterLabel"),
|
|||
|
|
"topics": topics,
|
|||
|
|
"plannerActions": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 8: Wave B (Flow + Apex body fetches)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _collect_wave_b_targets(bundle_parsed: dict) -> dict[str, list[str]]:
|
|||
|
|
"""Walk topics + plannerActions. Return {kind: [invocation_target, ...]}.
|
|||
|
|
|
|||
|
|
Deduplicates per-kind. Caller routes each kind through the appropriate
|
|||
|
|
fetch_soql helper.
|
|||
|
|
"""
|
|||
|
|
apex_names: set[str] = set()
|
|||
|
|
apex_ids: set[str] = set()
|
|||
|
|
flow_names: set[str] = set()
|
|||
|
|
flow_ids: set[str] = set()
|
|||
|
|
# Bug 1 fix (2026-05-05): prompt-template targets can arrive as either
|
|||
|
|
# DeveloperNames (classic) OR 0hf-prefix Ids (observed in live orgs —
|
|||
|
|
# GenAiFunctionDefinition.InvocationTarget sometimes stores the Id).
|
|||
|
|
# Route to separate buckets so the Id bucket can be resolved → DevName
|
|||
|
|
# by _normalize_prompt_template_id_targets before Wave B retrieve.
|
|||
|
|
prompt_template_ids: set[str] = set()
|
|||
|
|
unresolved: list[dict] = []
|
|||
|
|
|
|||
|
|
def _route(target: str, ttype: str) -> None:
|
|||
|
|
t = (ttype or "").lower()
|
|||
|
|
if not target:
|
|||
|
|
return
|
|||
|
|
# Bug 1 fix: prompt-template targets can arrive as either
|
|||
|
|
# DeveloperNames (classic) OR 0hf-prefix Ids. The Metadata API
|
|||
|
|
# retrieve expects DeveloperName; an Id sent verbatim returns
|
|||
|
|
# zero members and the target stays in _pending_fetches forever.
|
|||
|
|
# Route 0hf Ids into a separate bucket for post-lookup rewrite
|
|||
|
|
# via _normalize_prompt_template_id_targets (parallel to the
|
|||
|
|
# existing _normalize_flow_id_targets pattern).
|
|||
|
|
if t in ("prompttemplate", "generatepromptresponse") or t.startswith("prompt") or t.startswith("genai"):
|
|||
|
|
if looks_like_sf_id(target) and target[:3].lower() == "0hf":
|
|||
|
|
prompt_template_ids.add(target)
|
|||
|
|
return
|
|||
|
|
# bundle-declared kinds that never carry an Id.
|
|||
|
|
# Standard actions store their identifier (not an SF Id) directly
|
|||
|
|
# in InvocationTarget. Skip the ID router entirely — routing them
|
|||
|
|
# through resolve_or_unresolved would pollute `_unresolved` with
|
|||
|
|
# misleading "invalid-id-format" entries.
|
|||
|
|
if t == "standardinvocableaction":
|
|||
|
|
return
|
|||
|
|
# for flow/apex, only consult the ID router
|
|||
|
|
# when the target actually looks like a Salesforce Id. Classic
|
|||
|
|
# DeveloperNames (e.g. `MyFlow`, `AGNT_Foo`) don't
|
|||
|
|
# match the Id shape and used to land in _unresolved with reason
|
|||
|
|
# `invalid-id-format` — noise. Short-circuit: if it's not an Id
|
|||
|
|
# shape, skip the router and trust the invocationTargetType.
|
|||
|
|
if t in ("flow", "apex") and looks_like_sf_id(target):
|
|||
|
|
kind, _channel = resolve_or_unresolved(target, unresolved)
|
|||
|
|
if kind == "apex":
|
|||
|
|
apex_ids.add(target)
|
|||
|
|
return
|
|||
|
|
if kind in ("flow_definition", "flow_version"):
|
|||
|
|
flow_ids.add(target)
|
|||
|
|
return
|
|||
|
|
# Unknown Id prefix: resolve_or_unresolved already appended
|
|||
|
|
# to `unresolved` with reason "unknown-id-prefix:..." — fall
|
|||
|
|
# through; the target stays unrouted and surfaces there.
|
|||
|
|
return
|
|||
|
|
# Classic path: InvocationTarget is a DeveloperName.
|
|||
|
|
if t == "apex":
|
|||
|
|
apex_names.add(target)
|
|||
|
|
elif t == "flow":
|
|||
|
|
flow_names.add(target)
|
|||
|
|
# Any other unrecognized type: silently skip. parse_wave will
|
|||
|
|
# classify the node as UNKNOWN and it surfaces via _kind_counts.
|
|||
|
|
|
|||
|
|
for topic in bundle_parsed.get("topics", []) or []:
|
|||
|
|
for a in topic.get("actions", []) or []:
|
|||
|
|
_route(a.get("invocationTarget") or "",
|
|||
|
|
a.get("invocationTargetType") or "")
|
|||
|
|
for a in bundle_parsed.get("plannerActions", []) or []:
|
|||
|
|
_route(a.get("invocationTarget") or "",
|
|||
|
|
a.get("invocationTargetType") or "")
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"apex_names": sorted(apex_names),
|
|||
|
|
"apex_ids": sorted(apex_ids),
|
|||
|
|
"flow_names": sorted(flow_names),
|
|||
|
|
"flow_ids": sorted(flow_ids),
|
|||
|
|
"prompt_template_ids": sorted(prompt_template_ids),
|
|||
|
|
"_unresolved": unresolved,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fetch_wave_b_by_names(
|
|||
|
|
*,
|
|||
|
|
apex_names: list[str],
|
|||
|
|
apex_ids: list[str],
|
|||
|
|
flow_names: list[str],
|
|||
|
|
flow_ids: list[str],
|
|||
|
|
prompt_template_ids: list[str] | None = None,
|
|||
|
|
creds_provider: Callable[[], Tuple[str, str]],
|
|||
|
|
refresh_fn: Callable[[], Tuple[str, str]],
|
|||
|
|
api_version: str,
|
|||
|
|
org_alias: str,
|
|||
|
|
parallelism: int = 5,
|
|||
|
|
) -> dict:
|
|||
|
|
"""Fire body fetches for an explicit set of Apex/Flow identifiers.
|
|||
|
|
|
|||
|
|
Factored out of `_run_wave_b` so the iterative Wave B caller
|
|||
|
|
(`_iterate_wave_b`) can reuse the same shape on subflow rounds —
|
|||
|
|
every round's fetch concurrency + error handling semantics must
|
|||
|
|
match the initial round exactly (same parallel layer, same
|
|||
|
|
unresolved bookkeeping).
|
|||
|
|
|
|||
|
|
Returns a dict with:
|
|||
|
|
apex_rows: list of ApexClass records (Name keyed)
|
|||
|
|
flow_def_rows: list of FlowDefinition records (DeveloperName keyed)
|
|||
|
|
flow_metadata: dict {version_id: Flow record}
|
|||
|
|
unresolved: list of {id, reason} from fetch failures
|
|||
|
|
|
|||
|
|
Any empty input list short-circuits that branch — callers can pass
|
|||
|
|
subsets safely (e.g. subflow rounds typically pass only flow_names).
|
|||
|
|
"""
|
|||
|
|
unresolved: list[dict] = []
|
|||
|
|
|
|||
|
|
# First hop: resolve IDs back to names where applicable. The results
|
|||
|
|
# merge into the same rowsets the name-keyed path produces.
|
|||
|
|
# api_version threads into every fetch call.
|
|||
|
|
if apex_ids:
|
|||
|
|
try:
|
|||
|
|
apex_by_id_rows = fetch_apex_bodies_by_ids(
|
|||
|
|
apex_ids, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError) as e:
|
|||
|
|
unresolved.append({"kind": "APEX", "reason": f"apex-by-id-failed:{redact_error(e)}"})
|
|||
|
|
apex_by_id_rows = []
|
|||
|
|
else:
|
|||
|
|
apex_by_id_rows = []
|
|||
|
|
|
|||
|
|
if flow_ids:
|
|||
|
|
try:
|
|||
|
|
flow_def_by_id_rows = fetch_flow_definition_by_ids(
|
|||
|
|
flow_ids, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError) as e:
|
|||
|
|
unresolved.append({"kind": "FLOW", "reason": f"flow-def-by-id-failed:{redact_error(e)}"})
|
|||
|
|
flow_def_by_id_rows = []
|
|||
|
|
else:
|
|||
|
|
flow_def_by_id_rows = []
|
|||
|
|
|
|||
|
|
# Bug 1 fix: GenAiPromptTemplate is NOT SOQL-queryable (not on Tooling,
|
|||
|
|
# not on Data API). The only way to resolve Id → DeveloperName is
|
|||
|
|
# `sf org list metadata -m GenAiPromptTemplate`. We list every prompt
|
|||
|
|
# template in the org, build an Id → fullName map, filter to the Ids
|
|||
|
|
# we care about, and project to the same {Id, DeveloperName} shape the
|
|||
|
|
# rest of the pipeline expects.
|
|||
|
|
#
|
|||
|
|
# Failure is non-fatal — the Ids stay in the bucket and surface in
|
|||
|
|
# `_pending_fetches` rather than masking the real issue.
|
|||
|
|
if prompt_template_ids:
|
|||
|
|
try:
|
|||
|
|
rows = list_prompt_template_metadata(org_alias)
|
|||
|
|
except (SfCliError, AuthRequired) as e:
|
|||
|
|
unresolved.append({
|
|||
|
|
"kind": "PROMPT_TEMPLATE",
|
|||
|
|
"reason": f"prompt-template-listmetadata-failed:{redact_error(e)}",
|
|||
|
|
})
|
|||
|
|
rows = []
|
|||
|
|
wanted = set(prompt_template_ids)
|
|||
|
|
prompt_template_id_rows = [
|
|||
|
|
{"Id": r["id"], "DeveloperName": r["fullName"]}
|
|||
|
|
for r in rows
|
|||
|
|
if isinstance(r, dict) and r.get("id") in wanted and r.get("fullName")
|
|||
|
|
]
|
|||
|
|
else:
|
|||
|
|
prompt_template_id_rows = []
|
|||
|
|
|
|||
|
|
# Parallel layer: apex-by-name + flow-def-by-name. Empty name lists
|
|||
|
|
# still produce a task, but the downstream fetcher short-circuits on
|
|||
|
|
# empty input — the overhead is a single worker dispatch which is
|
|||
|
|
# cheaper than conditionally building the task list.
|
|||
|
|
name_layer = fetch_bodies_parallel(
|
|||
|
|
[
|
|||
|
|
partial(
|
|||
|
|
fetch_apex_bodies_by_names,
|
|||
|
|
apex_names, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
),
|
|||
|
|
partial(
|
|||
|
|
fetch_flow_definition_ids_by_names,
|
|||
|
|
flow_names, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
max_workers=parallelism,
|
|||
|
|
)
|
|||
|
|
apex_name_rows = name_layer[0][1] if name_layer[0][0] else []
|
|||
|
|
flow_def_name_rows = name_layer[1][1] if name_layer[1][0] else []
|
|||
|
|
# Bug D.1 fix: include the response body preview captured by
|
|||
|
|
# rest_client._query_once when the failure was an HTTPError. Without
|
|||
|
|
# it, all wave-B 400s collapse to "HTTP Error 400: Bad Request" with
|
|||
|
|
# no signal about which name caused the malformed query. Falls back
|
|||
|
|
# to None (existing shape) for non-HTTPError failures.
|
|||
|
|
for ok, r in (name_layer[0], name_layer[1]):
|
|||
|
|
if not ok:
|
|||
|
|
entry = {"kind": "APEX_OR_FLOW", "reason": f"wave-b-batch-failed:{redact_error(r)}"}
|
|||
|
|
preview = getattr(r, "_response_body_preview", None)
|
|||
|
|
if preview:
|
|||
|
|
entry["response_body_preview"] = preview
|
|||
|
|
unresolved.append(entry)
|
|||
|
|
|
|||
|
|
apex_rows = [*apex_name_rows, *apex_by_id_rows]
|
|||
|
|
flow_def_rows = [*flow_def_name_rows, *flow_def_by_id_rows]
|
|||
|
|
|
|||
|
|
# Second hop: one Flow.Metadata single-row call per ActiveVersionId.
|
|||
|
|
# Parallelized via fetch_bodies_parallel.
|
|||
|
|
version_ids = [
|
|||
|
|
r.get("ActiveVersionId") for r in flow_def_rows
|
|||
|
|
if r.get("ActiveVersionId")
|
|||
|
|
]
|
|||
|
|
metadata_tasks = [
|
|||
|
|
partial(
|
|||
|
|
fetch_flow_metadata,
|
|||
|
|
vid, creds_provider,
|
|||
|
|
api_version=api_version, on_401_refresh=refresh_fn,
|
|||
|
|
)
|
|||
|
|
for vid in version_ids
|
|||
|
|
]
|
|||
|
|
metadata_results = fetch_bodies_parallel(
|
|||
|
|
metadata_tasks, max_workers=parallelism,
|
|||
|
|
)
|
|||
|
|
flow_metadata: dict[str, dict] = {}
|
|||
|
|
for vid, (ok, result_or_exc) in zip(version_ids, metadata_results):
|
|||
|
|
if ok and isinstance(result_or_exc, dict):
|
|||
|
|
flow_metadata[vid] = result_or_exc
|
|||
|
|
elif not ok:
|
|||
|
|
unresolved.append({
|
|||
|
|
"kind": "FLOW",
|
|||
|
|
"version_id": vid,
|
|||
|
|
"reason": f"flow-metadata-failed:{redact_error(result_or_exc)}",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"apex_rows": apex_rows,
|
|||
|
|
"flow_def_rows": flow_def_rows,
|
|||
|
|
"flow_metadata": flow_metadata,
|
|||
|
|
"prompt_template_id_rows": prompt_template_id_rows,
|
|||
|
|
"unresolved": unresolved,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_wave_b(
|
|||
|
|
bundle_parsed: dict,
|
|||
|
|
creds_provider: Callable[[], Tuple[str, str]],
|
|||
|
|
refresh_fn: Callable[[], Tuple[str, str]],
|
|||
|
|
*,
|
|||
|
|
api_version: str,
|
|||
|
|
org_alias: str,
|
|||
|
|
parallelism: int = 5,
|
|||
|
|
) -> dict:
|
|||
|
|
"""Fire body fetches for every Apex/Flow target in the bundle.
|
|||
|
|
|
|||
|
|
Returns a dict with:
|
|||
|
|
apex_rows: list of ApexClass records (Name keyed)
|
|||
|
|
flow_def_rows: list of FlowDefinition records (DeveloperName keyed)
|
|||
|
|
flow_metadata: dict {version_id: Flow record}
|
|||
|
|
unresolved: list of {id, reason} from NGA ID routing
|
|||
|
|
|
|||
|
|
Only covers the top-level identifiers enumerated directly in
|
|||
|
|
`bundle_parsed`. Subflows referenced inside fetched Flow.Metadata
|
|||
|
|
bodies (e.g. shared utility flows like `handleFlowFault`) are
|
|||
|
|
discovered AFTER this function returns and need a second round of
|
|||
|
|
fetching — see `_iterate_wave_b`.
|
|||
|
|
"""
|
|||
|
|
targets = _collect_wave_b_targets(bundle_parsed)
|
|||
|
|
|
|||
|
|
fetched = _fetch_wave_b_by_names(
|
|||
|
|
apex_names=targets["apex_names"],
|
|||
|
|
apex_ids=targets["apex_ids"],
|
|||
|
|
flow_names=targets["flow_names"],
|
|||
|
|
flow_ids=targets["flow_ids"],
|
|||
|
|
prompt_template_ids=targets.get("prompt_template_ids") or [],
|
|||
|
|
creds_provider=creds_provider,
|
|||
|
|
refresh_fn=refresh_fn,
|
|||
|
|
api_version=api_version,
|
|||
|
|
org_alias=org_alias,
|
|||
|
|
parallelism=parallelism,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Merge the routing-layer unresolved entries (unknown-id-prefix, etc.)
|
|||
|
|
# with the fetch-layer ones. Order: routing first, fetch second — it's
|
|||
|
|
# the order they're discovered in the pipeline.
|
|||
|
|
fetched["unresolved"] = [*targets["_unresolved"], *fetched["unresolved"]]
|
|||
|
|
return fetched
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# iterative Wave B for nested-subflow discovery
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _extract_refs_from_flow_metadata(
|
|||
|
|
flow_metadata: dict[str, dict],
|
|||
|
|
) -> Tuple[set[str], set[str]]:
|
|||
|
|
"""Scan fetched Flow.Metadata dicts for downstream subflow + apex refs.
|
|||
|
|
|
|||
|
|
Returns `(subflow_names, apex_names)` — both DeveloperName sets.
|
|||
|
|
Subsets of the data that `_build_flow_children` walks, narrowed to
|
|||
|
|
just the name-extraction we need before we have enough information
|
|||
|
|
to build the full child list.
|
|||
|
|
|
|||
|
|
The extractor is intentionally permissive: missing keys / non-dict
|
|||
|
|
entries / blank names all drop silently. Parsing shape mirrors the
|
|||
|
|
`Metadata.actionCalls[]` + `Metadata.subflows[]` layout documented
|
|||
|
|
in the fetch_flow_metadata docstring and exercised by the
|
|||
|
|
CLASSIC_FLOW_METADATA fixture.
|
|||
|
|
|
|||
|
|
Apex discovery from actionCalls is deliberate — a subflow body CAN
|
|||
|
|
reference a new Apex class the parent tree didn't see (actionType
|
|||
|
|
== "apex" && actionName == <new class>). Missing those means the
|
|||
|
|
iterated tree has unexpanded APEX leaves even after flows resolve.
|
|||
|
|
"""
|
|||
|
|
subflow_names: set[str] = set()
|
|||
|
|
apex_names: set[str] = set()
|
|||
|
|
for record in (flow_metadata or {}).values():
|
|||
|
|
if not isinstance(record, dict):
|
|||
|
|
continue
|
|||
|
|
md = record.get("Metadata")
|
|||
|
|
if not isinstance(md, dict):
|
|||
|
|
continue
|
|||
|
|
for ac in md.get("actionCalls") or []:
|
|||
|
|
if not isinstance(ac, dict):
|
|||
|
|
continue
|
|||
|
|
if (ac.get("actionType") or "") == "apex":
|
|||
|
|
nm = ac.get("actionName") or ""
|
|||
|
|
if nm:
|
|||
|
|
apex_names.add(nm)
|
|||
|
|
for sub in md.get("subflows") or []:
|
|||
|
|
if not isinstance(sub, dict):
|
|||
|
|
continue
|
|||
|
|
nm = sub.get("flowName") or ""
|
|||
|
|
if nm:
|
|||
|
|
subflow_names.add(nm)
|
|||
|
|
return subflow_names, apex_names
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _iterate_wave_b(
|
|||
|
|
initial_wave_b: dict,
|
|||
|
|
creds_provider: Callable[[], Tuple[str, str]],
|
|||
|
|
refresh_fn: Callable[[], Tuple[str, str]],
|
|||
|
|
*,
|
|||
|
|
api_version: str,
|
|||
|
|
org_alias: str,
|
|||
|
|
parallelism: int = 5,
|
|||
|
|
max_iterations: int = 5,
|
|||
|
|
) -> dict:
|
|||
|
|
"""Drive Wave B to fixed-point for nested subflow / apex discovery.
|
|||
|
|
|
|||
|
|
The initial `_run_wave_b` call only enumerates top-level flow refs
|
|||
|
|
from `bundle_parsed`. When those flow bodies reference subflows
|
|||
|
|
(via `Metadata.subflows[].flowName`) or new Apex classes (via
|
|||
|
|
`Metadata.actionCalls[]`), their bodies were never fetched — so
|
|||
|
|
`_build_flow_children` would shape them as leaf nodes with no
|
|||
|
|
children even though the real subtree has content.
|
|||
|
|
|
|||
|
|
Per round:
|
|||
|
|
1. Extract subflow + apex refs from the current flow_metadata.
|
|||
|
|
2. Diff against already-fetched DeveloperNames.
|
|||
|
|
3. If diff empty → converged, return.
|
|||
|
|
4. Fetch the diff via `_fetch_wave_b_by_names`.
|
|||
|
|
5. Merge into the running wave_b dict.
|
|||
|
|
6. Repeat.
|
|||
|
|
|
|||
|
|
Safety:
|
|||
|
|
* `max_iterations=5` — empirically 2-3 is enough on real bots
|
|||
|
|
(handleFlowFault etc.). Hit the cap → remaining unfetched names
|
|||
|
|
surface via parse_wave's `_pending_fetches.FLOW` (their names
|
|||
|
|
are never added to `visited_by_kind["FLOW"]` since they're
|
|||
|
|
absent from `flow_def_rows`) and a marker lands in `unresolved`
|
|||
|
|
for triage.
|
|||
|
|
* Fetch failures in a round are tolerated by `_fetch_wave_b_by_names`
|
|||
|
|
— the affected names simply don't make it into `flow_def_rows`
|
|||
|
|
and naturally surface as pending.
|
|||
|
|
|
|||
|
|
Returns the (possibly enlarged) wave_b dict; caller mutates nothing.
|
|||
|
|
"""
|
|||
|
|
if max_iterations <= 0:
|
|||
|
|
return initial_wave_b
|
|||
|
|
|
|||
|
|
# Running state: copy so callers see their original dict untouched.
|
|||
|
|
merged_apex_rows = list(initial_wave_b.get("apex_rows") or [])
|
|||
|
|
merged_flow_def_rows = list(initial_wave_b.get("flow_def_rows") or [])
|
|||
|
|
merged_flow_metadata: dict[str, dict] = dict(
|
|||
|
|
initial_wave_b.get("flow_metadata") or {}
|
|||
|
|
)
|
|||
|
|
merged_unresolved = list(initial_wave_b.get("unresolved") or [])
|
|||
|
|
|
|||
|
|
fetched_flow_names: set[str] = {
|
|||
|
|
r.get("DeveloperName") for r in merged_flow_def_rows
|
|||
|
|
if r.get("DeveloperName")
|
|||
|
|
}
|
|||
|
|
fetched_apex_names: set[str] = {
|
|||
|
|
r.get("Name") for r in merged_apex_rows if r.get("Name")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _round in range(max_iterations):
|
|||
|
|
subflow_names, apex_names = _extract_refs_from_flow_metadata(
|
|||
|
|
merged_flow_metadata
|
|||
|
|
)
|
|||
|
|
new_flow_names = sorted(subflow_names - fetched_flow_names)
|
|||
|
|
new_apex_names = sorted(apex_names - fetched_apex_names)
|
|||
|
|
if not new_flow_names and not new_apex_names:
|
|||
|
|
# Fixed-point reached.
|
|||
|
|
return {
|
|||
|
|
"apex_rows": merged_apex_rows,
|
|||
|
|
"flow_def_rows": merged_flow_def_rows,
|
|||
|
|
"flow_metadata": merged_flow_metadata,
|
|||
|
|
"prompt_template_id_rows": initial_wave_b.get(
|
|||
|
|
"prompt_template_id_rows"
|
|||
|
|
) or [],
|
|||
|
|
"unresolved": merged_unresolved,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
round_result = _fetch_wave_b_by_names(
|
|||
|
|
apex_names=new_apex_names,
|
|||
|
|
apex_ids=[],
|
|||
|
|
flow_names=new_flow_names,
|
|||
|
|
flow_ids=[],
|
|||
|
|
creds_provider=creds_provider,
|
|||
|
|
refresh_fn=refresh_fn,
|
|||
|
|
api_version=api_version,
|
|||
|
|
org_alias=org_alias,
|
|||
|
|
parallelism=parallelism,
|
|||
|
|
)
|
|||
|
|
merged_apex_rows.extend(round_result["apex_rows"])
|
|||
|
|
merged_flow_def_rows.extend(round_result["flow_def_rows"])
|
|||
|
|
merged_flow_metadata.update(round_result["flow_metadata"])
|
|||
|
|
merged_unresolved.extend(round_result["unresolved"])
|
|||
|
|
|
|||
|
|
# Update fetched-name bookkeeping — we only track rows we
|
|||
|
|
# actually received (fetch failure ⇒ not fetched ⇒ will be
|
|||
|
|
# retried next round, bounded by the iteration cap).
|
|||
|
|
for r in round_result["flow_def_rows"]:
|
|||
|
|
nm = r.get("DeveloperName")
|
|||
|
|
if nm:
|
|||
|
|
fetched_flow_names.add(nm)
|
|||
|
|
for r in round_result["apex_rows"]:
|
|||
|
|
nm = r.get("Name")
|
|||
|
|
if nm:
|
|||
|
|
fetched_apex_names.add(nm)
|
|||
|
|
|
|||
|
|
# Iteration cap hit. Scan one more time to see what's still pending
|
|||
|
|
# so we can surface it clearly in `unresolved`. These names will also
|
|||
|
|
# land in `_pending_fetches.FLOW` via parse_wave's visited-vs-pending
|
|||
|
|
# diff (they're absent from flow_def_rows ⇒ absent from
|
|||
|
|
# visited_by_kind["FLOW"]).
|
|||
|
|
subflow_names, apex_names = _extract_refs_from_flow_metadata(
|
|||
|
|
merged_flow_metadata
|
|||
|
|
)
|
|||
|
|
still_pending_flows = sorted(subflow_names - fetched_flow_names)
|
|||
|
|
still_pending_apex = sorted(apex_names - fetched_apex_names)
|
|||
|
|
for nm in still_pending_flows:
|
|||
|
|
merged_unresolved.append({
|
|||
|
|
"kind": "FLOW",
|
|||
|
|
"api_name": nm,
|
|||
|
|
"reason": f"wave-b-iteration-cap:{max_iterations}-rounds-exhausted",
|
|||
|
|
})
|
|||
|
|
for nm in still_pending_apex:
|
|||
|
|
merged_unresolved.append({
|
|||
|
|
"kind": "APEX",
|
|||
|
|
"api_name": nm,
|
|||
|
|
"reason": f"wave-b-iteration-cap:{max_iterations}-rounds-exhausted",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"apex_rows": merged_apex_rows,
|
|||
|
|
"flow_def_rows": merged_flow_def_rows,
|
|||
|
|
"flow_metadata": merged_flow_metadata,
|
|||
|
|
"prompt_template_id_rows": initial_wave_b.get(
|
|||
|
|
"prompt_template_id_rows"
|
|||
|
|
) or [],
|
|||
|
|
"unresolved": merged_unresolved,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# build flow_children from in-memory Wave B metadata
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_flow_children(
|
|||
|
|
flow_metadata: dict[str, dict],
|
|||
|
|
flow_def_rows: list[dict],
|
|||
|
|
) -> dict[str, list[dict]]:
|
|||
|
|
"""Produce `{flow_developer_name: [child_ref, ...]}` from Wave B data.
|
|||
|
|
|
|||
|
|
Mirrors the shape `parse_wave.harvest_waves` produces from on-disk
|
|||
|
|
flow XML, but sourced from the in-memory `Flow.Metadata` JSON that
|
|||
|
|
Wave B already fetched via the Tooling REST API (the `Metadata`
|
|||
|
|
complexvalue field decodes to a plain dict — see
|
|||
|
|
`fetch_flow_metadata` + test fixture `CLASSIC_FLOW_METADATA`). The
|
|||
|
|
shape parse_wave expects per child:
|
|||
|
|
|
|||
|
|
{"kind": "APEX", "api_name": <name>, "element_name": <elem>}
|
|||
|
|
{"kind": "PROMPT_TEMPLATE", "api_name": <name>, "element_name": <elem>}
|
|||
|
|
{"kind": "STANDARD_ACTION", "api_name": <name>, "element_name": <elem>,
|
|||
|
|
"invocation_type": <actionType>}
|
|||
|
|
{"kind": "FLOW", "api_name": <target_flow_name>,
|
|||
|
|
"element_name": <elem>}
|
|||
|
|
{"kind": "UNKNOWN", "api_name": <name>, "element_name": <elem>,
|
|||
|
|
"invocation_type": <actionType>}
|
|||
|
|
|
|||
|
|
Keying is by FlowDefinition.DeveloperName (not ActiveVersionId) —
|
|||
|
|
`walk_and_inflate` looks up by `leaf["api_name"]`, which the bundle
|
|||
|
|
populates with the Flow's DeveloperName.
|
|||
|
|
|
|||
|
|
Flows whose ActiveVersionId is missing (inactive / draft-only) or
|
|||
|
|
whose fetched metadata was None are silently skipped — they never
|
|||
|
|
had a body to walk. actionCall entries missing required fields
|
|||
|
|
(`actionType`, `actionName`, or `name`) degrade to UNKNOWN via
|
|||
|
|
`classify_action_call`, which already tolerates blanks. Subflow
|
|||
|
|
entries missing `flowName` are dropped entirely (no api_name to
|
|||
|
|
descend into).
|
|||
|
|
"""
|
|||
|
|
import parse_wave
|
|||
|
|
|
|||
|
|
# Build ActiveVersionId → DeveloperName lookup. FlowDefinition rows with
|
|||
|
|
# no ActiveVersionId (inactive flows, or flows only materializing a
|
|||
|
|
# Latest version) skip this map — `flow_metadata` is keyed on
|
|||
|
|
# ActiveVersionId too, so they drop out symmetrically.
|
|||
|
|
version_to_name: dict[str, str] = {}
|
|||
|
|
for row in flow_def_rows or []:
|
|||
|
|
vid = row.get("ActiveVersionId")
|
|||
|
|
dev_name = row.get("DeveloperName")
|
|||
|
|
if vid and dev_name:
|
|||
|
|
version_to_name[vid] = dev_name
|
|||
|
|
|
|||
|
|
flow_children: dict[str, list[dict]] = {}
|
|||
|
|
for version_id, record in (flow_metadata or {}).items():
|
|||
|
|
flow_name = version_to_name.get(version_id)
|
|||
|
|
if not flow_name:
|
|||
|
|
# No matching FlowDefinition row — can't key the children list.
|
|||
|
|
continue
|
|||
|
|
if not isinstance(record, dict):
|
|||
|
|
continue
|
|||
|
|
md = record.get("Metadata") or {}
|
|||
|
|
if not isinstance(md, dict):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
children: list[dict] = []
|
|||
|
|
|
|||
|
|
# actionCalls — each entry has `name` (element id), `actionType`,
|
|||
|
|
# `actionName`. `actionType`/`actionName` may be missing on edge
|
|||
|
|
# cases (flow authored against an older API version that omits
|
|||
|
|
# the field); classify_action_call tolerates empty strings and
|
|||
|
|
# will return UNKNOWN.
|
|||
|
|
for ac in md.get("actionCalls") or []:
|
|||
|
|
if not isinstance(ac, dict):
|
|||
|
|
continue
|
|||
|
|
element_name = ac.get("name")
|
|||
|
|
action_type = ac.get("actionType") or ""
|
|||
|
|
action_name = ac.get("actionName") or ""
|
|||
|
|
if not element_name:
|
|||
|
|
# An unnamed actionCall can't be addressed in the tree
|
|||
|
|
# view; drop rather than synthesize a placeholder.
|
|||
|
|
continue
|
|||
|
|
children.append(
|
|||
|
|
parse_wave.classify_action_call(action_type, action_name, element_name)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# subflows — each has `name` (element id) + `flowName` (target).
|
|||
|
|
for sub in md.get("subflows") or []:
|
|||
|
|
if not isinstance(sub, dict):
|
|||
|
|
continue
|
|||
|
|
element_name = sub.get("name")
|
|||
|
|
target = sub.get("flowName")
|
|||
|
|
if not element_name or not target:
|
|||
|
|
continue
|
|||
|
|
children.append({
|
|||
|
|
"kind": "FLOW",
|
|||
|
|
"element_name": element_name,
|
|||
|
|
"api_name": target,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
flow_children[flow_name] = children
|
|||
|
|
|
|||
|
|
return flow_children
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Flow-ID → DeveloperName normalization
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_flow_id_targets(bundle_parsed: dict, flow_def_rows: list[dict]) -> None:
|
|||
|
|
"""Rewrite action invocationTargets that are Flow IDs → DeveloperNames.
|
|||
|
|
|
|||
|
|
Mutates `bundle_parsed` in place. No-op when no flow_def_rows match.
|
|||
|
|
|
|||
|
|
Classic bots occasionally store 300Uv-prefix FlowDefinition IDs or
|
|||
|
|
301-prefix Flow version IDs in `GenAiFunctionDefinition.InvocationTarget`
|
|||
|
|
instead of DeveloperNames. Wave B's FlowDefinition fetch (by-id branch)
|
|||
|
|
already resolved those IDs; the rows carry both Id + ActiveVersionId +
|
|||
|
|
DeveloperName. We build a bi-directional map (300-id → name, 301-id →
|
|||
|
|
name via ActiveVersionId) and rewrite every action whose target is a
|
|||
|
|
known ID.
|
|||
|
|
|
|||
|
|
Unmatched IDs (Flow genuinely not queryable or belongs to a managed
|
|||
|
|
package we can't see) stay as-is so the pipeline surfaces them in
|
|||
|
|
_pending_fetches rather than silently discarding.
|
|||
|
|
"""
|
|||
|
|
# Build ID → DeveloperName lookup from both Id and ActiveVersionId keys.
|
|||
|
|
id_to_name: dict[str, str] = {}
|
|||
|
|
for row in flow_def_rows or []:
|
|||
|
|
dev_name = row.get("DeveloperName")
|
|||
|
|
if not dev_name:
|
|||
|
|
continue
|
|||
|
|
row_id = row.get("Id")
|
|||
|
|
if row_id:
|
|||
|
|
id_to_name[row_id] = dev_name
|
|||
|
|
active_ver = row.get("ActiveVersionId")
|
|||
|
|
if active_ver:
|
|||
|
|
id_to_name[active_ver] = dev_name
|
|||
|
|
|
|||
|
|
if not id_to_name:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def _rewrite_action(action: dict) -> None:
|
|||
|
|
target = action.get("invocationTarget")
|
|||
|
|
ttype = (action.get("invocationTargetType") or "").strip().lower()
|
|||
|
|
if ttype != "flow" or not isinstance(target, str):
|
|||
|
|
return
|
|||
|
|
resolved = id_to_name.get(target)
|
|||
|
|
if resolved:
|
|||
|
|
# Preserve the original ID for traceability — consumers that
|
|||
|
|
# need it can still read `_original_invocation_target_id`.
|
|||
|
|
action["_original_invocation_target_id"] = target
|
|||
|
|
action["invocationTarget"] = resolved
|
|||
|
|
|
|||
|
|
for topic in bundle_parsed.get("topics") or []:
|
|||
|
|
for action in topic.get("actions") or []:
|
|||
|
|
_rewrite_action(action)
|
|||
|
|
for action in bundle_parsed.get("plannerActions") or []:
|
|||
|
|
_rewrite_action(action)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_prompt_template_id_targets(
|
|||
|
|
bundle_parsed: dict, prompt_template_rows: list[dict],
|
|||
|
|
) -> None:
|
|||
|
|
"""Rewrite action invocationTargets that are GenAiPromptTemplate Ids
|
|||
|
|
(0hf-prefix) → DeveloperNames.
|
|||
|
|
|
|||
|
|
Bug 1 fix (2026-05-05): when GenAiFunctionDefinition.InvocationTarget
|
|||
|
|
stores a 0hf-prefix prompt template Id instead of a DeveloperName
|
|||
|
|
(observed in live my-org-alias org), the Metadata API retrieve
|
|||
|
|
can't match it — Wave B enqueues the Id as-is and the template
|
|||
|
|
stays in `_pending_fetches.PROMPT_TEMPLATE` forever.
|
|||
|
|
|
|||
|
|
Parallel to `_normalize_flow_id_targets`: build an Id → DeveloperName
|
|||
|
|
map from `list_prompt_template_metadata` rows (projected to
|
|||
|
|
{Id, DeveloperName} shape by `_fetch_wave_b_by_names`), mutate the
|
|||
|
|
bundle in place. Unmatched Ids stay as-is (genuinely missing template,
|
|||
|
|
or the Metadata API listing failed).
|
|||
|
|
"""
|
|||
|
|
id_to_name: dict[str, str] = {}
|
|||
|
|
for row in prompt_template_rows or []:
|
|||
|
|
row_id = row.get("Id")
|
|||
|
|
dev_name = row.get("DeveloperName")
|
|||
|
|
if row_id and dev_name:
|
|||
|
|
id_to_name[row_id] = dev_name
|
|||
|
|
|
|||
|
|
if not id_to_name:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def _rewrite_action(action: dict) -> None:
|
|||
|
|
target = action.get("invocationTarget")
|
|||
|
|
ttype = (action.get("invocationTargetType") or "").strip().lower()
|
|||
|
|
# Match the same ttype predicate used by _route for prompt-template
|
|||
|
|
# targets — otherwise a Flow action whose target happens to collide
|
|||
|
|
# with a prompt template Id (unlikely but possible) wouldn't be
|
|||
|
|
# caught. _route's predicate is the authority on what's a prompt.
|
|||
|
|
is_prompt_ttype = (
|
|||
|
|
ttype in ("prompttemplate", "generatepromptresponse")
|
|||
|
|
or ttype.startswith("prompt")
|
|||
|
|
or ttype.startswith("genai")
|
|||
|
|
)
|
|||
|
|
if not is_prompt_ttype or not isinstance(target, str):
|
|||
|
|
return
|
|||
|
|
resolved = id_to_name.get(target)
|
|||
|
|
if resolved:
|
|||
|
|
action["_original_invocation_target_id"] = target
|
|||
|
|
action["invocationTarget"] = resolved
|
|||
|
|
|
|||
|
|
for topic in bundle_parsed.get("topics") or []:
|
|||
|
|
for action in topic.get("actions") or []:
|
|||
|
|
_rewrite_action(action)
|
|||
|
|
for action in bundle_parsed.get("plannerActions") or []:
|
|||
|
|
_rewrite_action(action)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_apex_id_targets(
|
|||
|
|
bundle_parsed: dict, apex_by_id_rows: list[dict],
|
|||
|
|
) -> None:
|
|||
|
|
"""Rewrite action invocationTargets that are Apex (01p-prefix Id)
|
|||
|
|
-> ApexClass Name.
|
|||
|
|
|
|||
|
|
Gap B fix (2026-05-05): when GenAiFunctionDefinition.InvocationTarget
|
|||
|
|
stores a 01p-prefix ApexClass Id instead of a class Name (observed in
|
|||
|
|
live my-org-alias org, e.g. 01p000000000000AAA ->
|
|||
|
|
MyController), downstream Wave B body retrieval enqueues
|
|||
|
|
the Id as-is into `_pending_fetches.APEX` and the tree renders the raw
|
|||
|
|
Id instead of the class name.
|
|||
|
|
|
|||
|
|
Parallel to `_normalize_prompt_template_id_targets`: build an Id ->
|
|||
|
|
Name map from `fetch_apex_bodies_by_ids` rows (ApexClass uses `Name`,
|
|||
|
|
not `DeveloperName`), mutate the bundle in place. Unmatched Ids stay
|
|||
|
|
as-is (genuinely missing class, or the by-Id fetch failed).
|
|||
|
|
"""
|
|||
|
|
id_to_name: dict[str, str] = {}
|
|||
|
|
for row in apex_by_id_rows or []:
|
|||
|
|
row_id = row.get("Id")
|
|||
|
|
class_name = row.get("Name")
|
|||
|
|
if row_id and class_name:
|
|||
|
|
id_to_name[row_id] = class_name
|
|||
|
|
|
|||
|
|
if not id_to_name:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def _rewrite_action(action: dict) -> None:
|
|||
|
|
target = action.get("invocationTarget")
|
|||
|
|
ttype = (action.get("invocationTargetType") or "").strip().lower()
|
|||
|
|
# Match the same ttype predicate used by _route for apex targets.
|
|||
|
|
# Mirrors the prompt-template normalizer's ttype gate so a Flow
|
|||
|
|
# action whose target happens to collide with an ApexClass Id
|
|||
|
|
# (unlikely but possible) isn't miscaught.
|
|||
|
|
is_apex_ttype = ttype in ("apex", "apexaction") or ttype.startswith("apex")
|
|||
|
|
if not is_apex_ttype or not isinstance(target, str):
|
|||
|
|
return
|
|||
|
|
# Prefix-gate the rewrite to the ApexClass key prefix (01p).
|
|||
|
|
# Mirrors the `target[:3].lower() == "01p"` check in _collect_wave_b_targets.
|
|||
|
|
if target[:3].lower() != "01p":
|
|||
|
|
return
|
|||
|
|
resolved = id_to_name.get(target)
|
|||
|
|
if resolved:
|
|||
|
|
action["_original_invocation_target_id"] = target
|
|||
|
|
action["invocationTarget"] = resolved
|
|||
|
|
|
|||
|
|
for topic in bundle_parsed.get("topics") or []:
|
|||
|
|
for action in topic.get("actions") or []:
|
|||
|
|
_rewrite_action(action)
|
|||
|
|
for action in bundle_parsed.get("plannerActions") or []:
|
|||
|
|
_rewrite_action(action)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Signature derivation — Apex + Flow (rendered in Section 7 of architecture.md)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _derive_apex_signature(apex_row: dict) -> str | None:
|
|||
|
|
"""Return a compact one-line signature string for an Apex class, or None.
|
|||
|
|
|
|||
|
|
Sourced from `SymbolTable.methods[]` on the ApexClass Tooling row. Picks
|
|||
|
|
the "primary" method:
|
|||
|
|
1. Prefer a method annotated `@InvocableMethod` (what Flow / Agentforce
|
|||
|
|
actually invoke — typically exactly one per Agentforce-relevant
|
|||
|
|
class).
|
|||
|
|
2. Else prefer a `global` or `public static` non-constructor method.
|
|||
|
|
3. Else fall back to the first method in SymbolTable source order.
|
|||
|
|
|
|||
|
|
Format: `<modifiers> <returnType> <name>(<p1_type> <p1_name>, ...)`. If
|
|||
|
|
`returnType` is absent (constructor shape) it's omitted. Parameters are
|
|||
|
|
rendered as `<type> <name>`; empty param list becomes `()`.
|
|||
|
|
|
|||
|
|
Returns None when `SymbolTable` is missing/empty or contains no method
|
|||
|
|
candidates. Body parsing is intentionally NOT attempted — too brittle.
|
|||
|
|
"""
|
|||
|
|
if not isinstance(apex_row, dict):
|
|||
|
|
return None
|
|||
|
|
symbol_table = apex_row.get("SymbolTable")
|
|||
|
|
if not isinstance(symbol_table, dict):
|
|||
|
|
return None
|
|||
|
|
methods = symbol_table.get("methods")
|
|||
|
|
if not isinstance(methods, list) or not methods:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _is_invocable(m: dict) -> bool:
|
|||
|
|
anns = m.get("annotations") or []
|
|||
|
|
for ann in anns:
|
|||
|
|
if isinstance(ann, dict):
|
|||
|
|
if (ann.get("name") or "").lower() == "invocablemethod":
|
|||
|
|
return True
|
|||
|
|
elif isinstance(ann, str):
|
|||
|
|
if ann.lower() == "invocablemethod":
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def _is_constructor(m: dict) -> bool:
|
|||
|
|
# SymbolTable represents constructors via `constructors[]`, but some
|
|||
|
|
# payloads fold them into `methods[]` with a null/empty returnType.
|
|||
|
|
rt = m.get("returnType")
|
|||
|
|
return not rt
|
|||
|
|
|
|||
|
|
def _is_promoted(m: dict) -> bool:
|
|||
|
|
mods = [str(x).lower() for x in (m.get("modifiers") or [])]
|
|||
|
|
if _is_constructor(m):
|
|||
|
|
return False
|
|||
|
|
if "global" in mods:
|
|||
|
|
return True
|
|||
|
|
if "public" in mods and "static" in mods:
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
candidates = [m for m in methods if isinstance(m, dict)]
|
|||
|
|
if not candidates:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
primary: dict | None = next((m for m in candidates if _is_invocable(m)), None)
|
|||
|
|
if primary is None:
|
|||
|
|
primary = next((m for m in candidates if _is_promoted(m)), None)
|
|||
|
|
if primary is None:
|
|||
|
|
# Last-resort: first method. Skip if it's purely a private/local helper
|
|||
|
|
# with no chance of being the public entry point — those add noise.
|
|||
|
|
primary = candidates[0]
|
|||
|
|
mods = [str(x).lower() for x in (primary.get("modifiers") or [])]
|
|||
|
|
if "private" in mods and not _is_invocable(primary):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
modifiers = [str(x) for x in (primary.get("modifiers") or [])]
|
|||
|
|
return_type = primary.get("returnType") or ""
|
|||
|
|
name = primary.get("name") or ""
|
|||
|
|
if not name:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
params_out: list[str] = []
|
|||
|
|
for p in primary.get("parameters") or []:
|
|||
|
|
if not isinstance(p, dict):
|
|||
|
|
continue
|
|||
|
|
p_type = p.get("type") or ""
|
|||
|
|
p_name = p.get("name") or ""
|
|||
|
|
if p_type and p_name:
|
|||
|
|
params_out.append(f"{p_type} {p_name}")
|
|||
|
|
elif p_type:
|
|||
|
|
params_out.append(p_type)
|
|||
|
|
elif p_name:
|
|||
|
|
params_out.append(p_name)
|
|||
|
|
params_str = ", ".join(params_out)
|
|||
|
|
|
|||
|
|
parts: list[str] = []
|
|||
|
|
if modifiers:
|
|||
|
|
parts.append(" ".join(modifiers))
|
|||
|
|
if return_type:
|
|||
|
|
parts.append(return_type)
|
|||
|
|
parts.append(f"{name}({params_str})")
|
|||
|
|
return " ".join(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _derive_flow_signature(flow_metadata_record: dict) -> str | None:
|
|||
|
|
"""Return a compact signature string for a Flow, or None.
|
|||
|
|
|
|||
|
|
Sourced from `Metadata.variables[]` on the Tooling Flow record
|
|||
|
|
(`fetch_flow_metadata` output — the `flow_metadata_record` is the
|
|||
|
|
full Tooling row, and `.Metadata` is the decoded complexvalue dict).
|
|||
|
|
|
|||
|
|
Flow variables carry boolean `isInput` / `isOutput` flags. A variable
|
|||
|
|
with `isInput: true` contributes to the `in:` side; `isOutput: true`
|
|||
|
|
contributes to the `out:` side; a variable can set both (round-trip)
|
|||
|
|
and will appear on BOTH sides. Variables with neither flag are locals
|
|||
|
|
and are skipped.
|
|||
|
|
|
|||
|
|
Format: `in: <name>: <type>[, ...] | out: <name>: <type>[, ...]`.
|
|||
|
|
If only one side has entries, only that side is emitted. Returns None
|
|||
|
|
when neither side has entries.
|
|||
|
|
|
|||
|
|
Collection variables render as `List<<dataType>>`. sObject variables
|
|||
|
|
use the `objectType` field (e.g. `List<Case>` rather than
|
|||
|
|
`List<SObject>`). Insertion order from `variables[]` is preserved —
|
|||
|
|
we deliberately do not sort, to keep the signature aligned with the
|
|||
|
|
flow author's declared order.
|
|||
|
|
"""
|
|||
|
|
if not isinstance(flow_metadata_record, dict):
|
|||
|
|
return None
|
|||
|
|
md = flow_metadata_record.get("Metadata")
|
|||
|
|
if not isinstance(md, dict):
|
|||
|
|
# Test convenience: accept a bare Metadata dict, not just the full row.
|
|||
|
|
if "variables" in flow_metadata_record:
|
|||
|
|
md = flow_metadata_record
|
|||
|
|
else:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
variables = md.get("variables") or []
|
|||
|
|
if not isinstance(variables, list):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _render_type(var: dict) -> str:
|
|||
|
|
data_type = (var.get("dataType") or "").strip()
|
|||
|
|
obj_type = (var.get("objectType") or "").strip()
|
|||
|
|
apex_class = (var.get("apexClass") or "").strip()
|
|||
|
|
dt_lower = data_type.lower()
|
|||
|
|
# sObject variables carry the concrete SObject name in `objectType`
|
|||
|
|
# (e.g. `Case`). Apex-typed variables carry the concrete class name
|
|||
|
|
# in `apexClass` (e.g. `CX_OrgSelector.Org`). Without the apexClass
|
|||
|
|
# branch, collections of Apex-typed values render as the useless
|
|||
|
|
# `List<Apex>` instead of `List<CX_OrgSelector.Org>`.
|
|||
|
|
if dt_lower == "sobject" and obj_type:
|
|||
|
|
base = obj_type
|
|||
|
|
elif dt_lower == "apex" and apex_class:
|
|||
|
|
base = apex_class
|
|||
|
|
else:
|
|||
|
|
base = data_type or "Object"
|
|||
|
|
if var.get("isCollection"):
|
|||
|
|
return f"List<{base}>"
|
|||
|
|
return base
|
|||
|
|
|
|||
|
|
inputs: list[str] = []
|
|||
|
|
outputs: list[str] = []
|
|||
|
|
for v in variables:
|
|||
|
|
if not isinstance(v, dict):
|
|||
|
|
continue
|
|||
|
|
name = v.get("name")
|
|||
|
|
if not name:
|
|||
|
|
continue
|
|||
|
|
rendered = f"{name}: {_render_type(v)}"
|
|||
|
|
if v.get("isInput"):
|
|||
|
|
inputs.append(rendered)
|
|||
|
|
if v.get("isOutput"):
|
|||
|
|
outputs.append(rendered)
|
|||
|
|
|
|||
|
|
if not inputs and not outputs:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
parts: list[str] = []
|
|||
|
|
if inputs:
|
|||
|
|
parts.append("in: " + ", ".join(inputs))
|
|||
|
|
if outputs:
|
|||
|
|
parts.append("out: " + ", ".join(outputs))
|
|||
|
|
return " | ".join(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _stamp_signatures(
|
|||
|
|
node: dict,
|
|||
|
|
apex_sigs: dict[str, str],
|
|||
|
|
flow_sigs: dict[str, str],
|
|||
|
|
flow_reasons: dict[str, str] | None = None,
|
|||
|
|
) -> None:
|
|||
|
|
"""Recursively stamp `node["signature"]` on matching APEX / FLOW leaves.
|
|||
|
|
|
|||
|
|
Shared utility flows / helpers that appear in multiple branches all
|
|||
|
|
receive the same stamped signature — they point at the same
|
|||
|
|
FlowDefinition / ApexClass, so the signature is the same.
|
|||
|
|
|
|||
|
|
Gap 2 fix (2026-05-05): when a FLOW leaf has no signature but *does*
|
|||
|
|
have an explanation in `flow_reasons` (e.g. managed-pkg body not
|
|||
|
|
retrievable via the Data API fallback), stamp `node["_signature_reason"]`
|
|||
|
|
so the renderer can distinguish a silent hole from a known limitation.
|
|||
|
|
"""
|
|||
|
|
if not isinstance(node, dict):
|
|||
|
|
return
|
|||
|
|
kind = node.get("kind")
|
|||
|
|
name = node.get("api_name")
|
|||
|
|
if kind == "APEX" and name and name in apex_sigs:
|
|||
|
|
node["signature"] = apex_sigs[name]
|
|||
|
|
elif kind == "FLOW" and name:
|
|||
|
|
if name in flow_sigs:
|
|||
|
|
node["signature"] = flow_sigs[name]
|
|||
|
|
elif flow_reasons and name in flow_reasons:
|
|||
|
|
node["_signature_reason"] = flow_reasons[name]
|
|||
|
|
for child in node.get("children") or []:
|
|||
|
|
_stamp_signatures(child, apex_sigs, flow_sigs, flow_reasons)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Gap C (2026-05-05): prompt template body retrieve + stamp
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_prompt_ttype(ttype: str) -> bool:
|
|||
|
|
"""Mirror the ttype predicate used by `_route` /
|
|||
|
|
`_normalize_prompt_template_id_targets` so collection + rewrite +
|
|||
|
|
retrieve all recognize the same action kind."""
|
|||
|
|
t = (ttype or "").strip().lower()
|
|||
|
|
return (
|
|||
|
|
t in ("prompttemplate", "generatepromptresponse")
|
|||
|
|
or t.startswith("prompt")
|
|||
|
|
or t.startswith("genai")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _collect_prompt_template_names(bundle_parsed: dict) -> set[str]:
|
|||
|
|
"""Walk topics + plannerActions, collect prompt-template DeveloperNames.
|
|||
|
|
|
|||
|
|
Called AFTER `_normalize_prompt_template_id_targets` — any 0hf-prefix
|
|||
|
|
Id that resolved via Wave B's Id-list-metadata should already be a
|
|||
|
|
DeveloperName. Defensive: drop anything that still looks Id-shaped
|
|||
|
|
(0hf prefix) so we don't send a malformed
|
|||
|
|
`GenAiPromptTemplate:0hfUv...` spec to the retrieve CLI.
|
|||
|
|
"""
|
|||
|
|
names: set[str] = set()
|
|||
|
|
|
|||
|
|
def _collect(action: dict) -> None:
|
|||
|
|
ttype = action.get("invocationTargetType") or ""
|
|||
|
|
if not _is_prompt_ttype(ttype):
|
|||
|
|
return
|
|||
|
|
target = action.get("invocationTarget")
|
|||
|
|
if not isinstance(target, str) or not target:
|
|||
|
|
return
|
|||
|
|
if target[:3].lower() == "0hf":
|
|||
|
|
# Id never got rewritten — the list_metadata lookup missed it
|
|||
|
|
# (managed-pkg template, permission issue). Skip; it stays in
|
|||
|
|
# _pending_fetches with the Id as the key.
|
|||
|
|
return
|
|||
|
|
names.add(target)
|
|||
|
|
|
|||
|
|
for topic in bundle_parsed.get("topics") or []:
|
|||
|
|
for action in topic.get("actions") or []:
|
|||
|
|
_collect(action)
|
|||
|
|
for action in bundle_parsed.get("plannerActions") or []:
|
|||
|
|
_collect(action)
|
|||
|
|
return names
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _collect_flow_nested_prompt_template_names(
|
|||
|
|
node: dict,
|
|||
|
|
already_retrieved: dict[str, dict],
|
|||
|
|
out: set[str],
|
|||
|
|
) -> None:
|
|||
|
|
"""Walk the finalized tree and collect PROMPT_TEMPLATE leaves whose
|
|||
|
|
body wasn't stamped during the bundle-scoped retrieve.
|
|||
|
|
|
|||
|
|
Bundle-scoped `_collect_prompt_template_names` only sees prompt
|
|||
|
|
templates declared as top-level topic/planner InvocationTargets.
|
|||
|
|
Prompt templates referenced from inside a Flow (via a
|
|||
|
|
`generatePromptResponse` actionCall) are discovered later during
|
|||
|
|
Wave B's BFS of flow XML and end up as PROMPT_TEMPLATE leaves in the
|
|||
|
|
tree — but their names never reached the first retrieve, so their
|
|||
|
|
`_body_available` stays False and content is silently lost.
|
|||
|
|
|
|||
|
|
Defensive: drop 0hf-Id-shape names (managed-pkg templates whose Id
|
|||
|
|
never rewrote to a DeveloperName) — sending
|
|||
|
|
`GenAiPromptTemplate:0hfUv...` to the retrieve CLI yields a
|
|||
|
|
malformed spec; the Id stays in `_pending_fetches.PROMPT_TEMPLATE`
|
|||
|
|
as intended.
|
|||
|
|
"""
|
|||
|
|
if not isinstance(node, dict):
|
|||
|
|
return
|
|||
|
|
if node.get("kind") == "PROMPT_TEMPLATE":
|
|||
|
|
name = node.get("api_name")
|
|||
|
|
if (
|
|||
|
|
isinstance(name, str)
|
|||
|
|
and name
|
|||
|
|
and name[:3].lower() != "0hf"
|
|||
|
|
and not node.get("_body_available", False)
|
|||
|
|
):
|
|||
|
|
body = already_retrieved.get(name)
|
|||
|
|
if not (body and body.get("content")):
|
|||
|
|
out.add(name)
|
|||
|
|
for child in node.get("children") or []:
|
|||
|
|
_collect_flow_nested_prompt_template_names(child, already_retrieved, out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _stamp_prompt_template_bodies(
|
|||
|
|
node: dict,
|
|||
|
|
bodies: dict[str, dict],
|
|||
|
|
) -> None:
|
|||
|
|
"""Recursively stamp prompt-template body fields onto matching
|
|||
|
|
PROMPT_TEMPLATE leaves.
|
|||
|
|
|
|||
|
|
For each leaf whose `api_name` matches a key in `bodies`, attach:
|
|||
|
|
- master_label, content, inputs, active_version_identifier
|
|||
|
|
- _body_available = True
|
|||
|
|
|
|||
|
|
Leaves with no matching body get `_body_available = False` so the
|
|||
|
|
renderer can distinguish "no body retrieved" from "body successfully
|
|||
|
|
empty". Shared templates appearing in multiple branches receive the
|
|||
|
|
same stamped body (they reference the same DeveloperName).
|
|||
|
|
"""
|
|||
|
|
if not isinstance(node, dict):
|
|||
|
|
return
|
|||
|
|
kind = node.get("kind")
|
|||
|
|
name = node.get("api_name")
|
|||
|
|
if kind == "PROMPT_TEMPLATE" and name:
|
|||
|
|
body = bodies.get(name)
|
|||
|
|
if body:
|
|||
|
|
node["master_label"] = body.get("masterLabel")
|
|||
|
|
node["content"] = body.get("content")
|
|||
|
|
node["inputs"] = body.get("inputs") or []
|
|||
|
|
node["active_version_identifier"] = body.get(
|
|||
|
|
"activeVersionIdentifier"
|
|||
|
|
)
|
|||
|
|
node["_body_available"] = True
|
|||
|
|
else:
|
|||
|
|
node["_body_available"] = False
|
|||
|
|
for child in node.get("children") or []:
|
|||
|
|
_stamp_prompt_template_bodies(child, bodies)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 9: build declared_action_tree.json via parse_wave primitives
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_parse_wave(
|
|||
|
|
bot_info: dict,
|
|||
|
|
bundle_parsed: dict,
|
|||
|
|
wave_b: dict,
|
|||
|
|
args: argparse.Namespace,
|
|||
|
|
work_dir: Path,
|
|||
|
|
) -> dict:
|
|||
|
|
"""Invoke parse_wave's init_tree + root-children builder in-process.
|
|||
|
|
|
|||
|
|
parse_wave was written to be invoked as a subprocess with env vars
|
|||
|
|
carrying AGENT_API_NAME / AGENT_VERSION / BOT_ID / etc. For Phase 2
|
|||
|
|
Batch 1 we set those env vars transiently and call the module's
|
|||
|
|
public helpers — no subprocess overhead, same behavior.
|
|||
|
|
"""
|
|||
|
|
import parse_wave
|
|||
|
|
|
|||
|
|
# parse_wave reads these via os.environ.
|
|||
|
|
prior_env = {
|
|||
|
|
"AGENT_API_NAME": os.environ.get("AGENT_API_NAME"),
|
|||
|
|
"AGENT_VERSION": os.environ.get("AGENT_VERSION"),
|
|||
|
|
"BOT_ID": os.environ.get("BOT_ID"),
|
|||
|
|
"BOT_MASTER_LABEL": os.environ.get("BOT_MASTER_LABEL"),
|
|||
|
|
"VERSION_AUTO_PICKED": os.environ.get("VERSION_AUTO_PICKED"),
|
|||
|
|
}
|
|||
|
|
os.environ["AGENT_API_NAME"] = args.agent_api_name
|
|||
|
|
os.environ["AGENT_VERSION"] = bot_info["version"]
|
|||
|
|
os.environ["BOT_ID"] = bot_info["bot_id"]
|
|||
|
|
os.environ["BOT_MASTER_LABEL"] = bot_info["master_label"]
|
|||
|
|
os.environ["VERSION_AUTO_PICKED"] = "true" if bot_info["version_auto_picked"] else "false"
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Persist the bundle for parse_wave's finalize_cap path.
|
|||
|
|
bundle_out = work_dir / "_bundle_parsed.json"
|
|||
|
|
bundle_out.write_text(json.dumps(bundle_parsed, indent=2))
|
|||
|
|
|
|||
|
|
# Persist a fake _bot_definition.json so init_tree reads real
|
|||
|
|
# metadata for the agent block.
|
|||
|
|
bd_file = work_dir / "_bot_definition.json"
|
|||
|
|
bd_file.write_text(json.dumps({
|
|||
|
|
"result": {"records": [bot_info.get("bot_definition") or {}]},
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
tree = parse_wave.init_tree(work_dir, bundle_parsed)
|
|||
|
|
|
|||
|
|
# Build root children + track bundle-derived BFS refs.
|
|||
|
|
# call the public `empty_kind_sets` /
|
|||
|
|
# `BFS_KINDS` symbols — reaching into `parse_wave._*` reopened the
|
|||
|
|
# leading-underscore surface that closed for `redact_text`.
|
|||
|
|
visited_by_kind = parse_wave.empty_kind_sets()
|
|||
|
|
aux_visited: set[tuple[str, str]] = set()
|
|||
|
|
children, bundle_refs = parse_wave.build_root_children(
|
|||
|
|
bundle_parsed, visited_by_kind, aux_visited,
|
|||
|
|
)
|
|||
|
|
tree["root"]["children"] = children
|
|||
|
|
|
|||
|
|
# Merge Flow metadata into flow_children. parse_wave wants this as
|
|||
|
|
# `{flow_name: [child_refs]}`. Build it from the in-memory
|
|||
|
|
# `Flow.Metadata` JSON Wave B already fetched — the Tooling REST
|
|||
|
|
# API decodes the `Metadata` complexvalue field into a plain dict,
|
|||
|
|
# so no XML parsing is needed (see `_build_flow_children` +
|
|||
|
|
# fixture `CLASSIC_FLOW_METADATA`). Without this, every FLOW leaf
|
|||
|
|
# in `metadata_tree.json` shipped with `children: []` and the
|
|||
|
|
# user's recursive-tree view was flat.
|
|||
|
|
flow_children = _build_flow_children(
|
|||
|
|
wave_b["flow_metadata"], wave_b["flow_def_rows"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# BFS routing for bundle refs.
|
|||
|
|
pending_by_kind, _cycles = parse_wave.bfs_step(
|
|||
|
|
parse_wave.empty_kind_sets(), visited_by_kind, bundle_refs,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Depth-cap accumulator threaded through inflate.
|
|||
|
|
depth_cap_pending = parse_wave.empty_kind_sets()
|
|||
|
|
parse_wave.walk_and_inflate(
|
|||
|
|
tree["root"], flow_children, pending_out=depth_cap_pending,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Merge any Apex / Flow we actually fetched into visited — they
|
|||
|
|
# no longer count as pending.
|
|||
|
|
for row in wave_b["apex_rows"]:
|
|||
|
|
nm = row.get("Name")
|
|||
|
|
if nm:
|
|||
|
|
visited_by_kind["APEX"].add(nm)
|
|||
|
|
for row in wave_b["flow_def_rows"]:
|
|||
|
|
nm = row.get("DeveloperName")
|
|||
|
|
if nm:
|
|||
|
|
visited_by_kind["FLOW"].add(nm)
|
|||
|
|
# Gap C (2026-05-05): prompt templates whose bodies we retrieved
|
|||
|
|
# count as visited and must drop out of _pending_fetches.
|
|||
|
|
# Templates the retrieve didn't return (permission / managed-pkg /
|
|||
|
|
# retrieve failure) stay out of `visited` so they surface there,
|
|||
|
|
# mirroring the Flow-by-name "no rows = stays pending" pattern.
|
|||
|
|
prompt_template_bodies = wave_b.get("prompt_template_bodies") or {}
|
|||
|
|
for name in prompt_template_bodies:
|
|||
|
|
if name:
|
|||
|
|
visited_by_kind["PROMPT_TEMPLATE"].add(name)
|
|||
|
|
|
|||
|
|
# Stamp Apex / Flow signatures onto every matching tree node so
|
|||
|
|
# render_architecture.py can populate Section 7. Wave B already
|
|||
|
|
# fetched SymbolTable (Apex) + Metadata (Flow); we just project it
|
|||
|
|
# onto `{kind, api_name}` leaves here. Shared utility flows /
|
|||
|
|
# helpers that appear in multiple positions all receive the same
|
|||
|
|
# stamped signature — that's the correct behavior (same definition).
|
|||
|
|
apex_sigs: dict[str, str] = {}
|
|||
|
|
for row in wave_b["apex_rows"]:
|
|||
|
|
nm = row.get("Name")
|
|||
|
|
if not nm:
|
|||
|
|
continue
|
|||
|
|
sig = _derive_apex_signature(row)
|
|||
|
|
if sig:
|
|||
|
|
apex_sigs[nm] = sig
|
|||
|
|
|
|||
|
|
flow_sigs: dict[str, str] = {}
|
|||
|
|
# Gap 2 fix (2026-05-05): explain silent signature holes. Managed
|
|||
|
|
# flows come back from `fetch_flow_definition_ids_by_names` via the
|
|||
|
|
# FlowDefinitionView fallback with _source="FlowDefinitionView",
|
|||
|
|
# ActiveVersionId=None, _body_available=False — by design, since
|
|||
|
|
# managed-pkg flow bodies are IP-protected. Previously every such
|
|||
|
|
# row produced an unexplained "_Signature not captured._" in the
|
|||
|
|
# rendered markdown. Now we surface the structural reason.
|
|||
|
|
#
|
|||
|
|
# For the generic "no usable metadata record" case (Tooling row
|
|||
|
|
# present but flow_metadata missing — e.g. metadata fetch failed
|
|||
|
|
# or the flow has no active version at all) we also stamp a reason
|
|||
|
|
# so the renderer never silently drops the node.
|
|||
|
|
flow_reasons: dict[str, str] = {}
|
|||
|
|
flow_metadata_by_vid = wave_b.get("flow_metadata") or {}
|
|||
|
|
for row in wave_b["flow_def_rows"]:
|
|||
|
|
dev_name = row.get("DeveloperName") or row.get("_bare_developer_name")
|
|||
|
|
if not dev_name:
|
|||
|
|
continue
|
|||
|
|
source = row.get("_source") or ""
|
|||
|
|
if source == "FlowDefinitionView":
|
|||
|
|
ns = row.get("NamespacePrefix") or ""
|
|||
|
|
reason = (
|
|||
|
|
f"managed-package (ns={ns}); body not retrievable via Tooling/Metadata API"
|
|||
|
|
if ns
|
|||
|
|
else "managed-package; body not retrievable via Tooling/Metadata API"
|
|||
|
|
)
|
|||
|
|
flow_reasons[dev_name] = reason
|
|||
|
|
continue
|
|||
|
|
active_ver = row.get("ActiveVersionId")
|
|||
|
|
if not active_ver:
|
|||
|
|
flow_reasons[dev_name] = "no active version"
|
|||
|
|
continue
|
|||
|
|
md_record = flow_metadata_by_vid.get(active_ver)
|
|||
|
|
if not md_record:
|
|||
|
|
flow_reasons[dev_name] = "flow metadata fetch returned no record"
|
|||
|
|
continue
|
|||
|
|
sig = _derive_flow_signature(md_record)
|
|||
|
|
if sig:
|
|||
|
|
flow_sigs[dev_name] = sig
|
|||
|
|
else:
|
|||
|
|
flow_reasons[dev_name] = "flow metadata has no in/out params"
|
|||
|
|
|
|||
|
|
_stamp_signatures(tree["root"], apex_sigs, flow_sigs, flow_reasons)
|
|||
|
|
|
|||
|
|
# Gap C: stamp retrieved prompt-template bodies onto the tree.
|
|||
|
|
# Leaves with a matching body gain content/master_label/inputs;
|
|||
|
|
# leaves without get `_body_available = False` so the renderer
|
|||
|
|
# can distinguish "retrieve missed" from "body truly absent".
|
|||
|
|
_stamp_prompt_template_bodies(tree["root"], prompt_template_bodies)
|
|||
|
|
|
|||
|
|
for kind in parse_wave.BFS_KINDS:
|
|||
|
|
pending_by_kind[kind] |= depth_cap_pending.get(kind, set())
|
|||
|
|
|
|||
|
|
depth_cap_tripped = any(depth_cap_pending[k] for k in parse_wave.BFS_KINDS)
|
|||
|
|
|
|||
|
|
tree["_pending_fetches"] = {
|
|||
|
|
k: sorted(pending_by_kind.get(k, set()) - visited_by_kind.get(k, set()))
|
|||
|
|
for k in parse_wave.BFS_KINDS
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
any_pending = any(tree["_pending_fetches"][k] for k in parse_wave.BFS_KINDS)
|
|||
|
|
if depth_cap_tripped:
|
|||
|
|
tree["_partial"] = True
|
|||
|
|
tree["_partial_reason"] = "max-depth-cap"
|
|||
|
|
elif any_pending:
|
|||
|
|
tree["_partial"] = True
|
|||
|
|
if not tree.get("_partial_reason"):
|
|||
|
|
tree["_partial_reason"] = "pending-refs"
|
|||
|
|
else:
|
|||
|
|
tree["_partial"] = False
|
|||
|
|
tree["_partial_reason"] = None
|
|||
|
|
|
|||
|
|
# Surface Wave-B-level unresolved items (e.g. unknown ID prefixes).
|
|||
|
|
for u in wave_b.get("unresolved", []) or []:
|
|||
|
|
tree.setdefault("_unresolved", []).append(u)
|
|||
|
|
|
|||
|
|
node_count, depth, kind_counts = parse_wave.compute_stats(tree["root"])
|
|||
|
|
tree["node_count"] = node_count
|
|||
|
|
tree["depth"] = depth
|
|||
|
|
tree["_kind_counts"] = kind_counts
|
|||
|
|
|
|||
|
|
all_visited: set[tuple[str, str]] = set(aux_visited)
|
|||
|
|
for kind, names in visited_by_kind.items():
|
|||
|
|
for n in names:
|
|||
|
|
all_visited.add((kind, n))
|
|||
|
|
tree["_visited"] = [list(v) for v in sorted(all_visited)]
|
|||
|
|
|
|||
|
|
tree_path = work_dir / "declared_action_tree.json"
|
|||
|
|
tmp = tree_path.with_suffix(tree_path.suffix + ".tmp")
|
|||
|
|
tmp.write_text(json.dumps(tree, indent=2))
|
|||
|
|
os.replace(tmp, tree_path)
|
|||
|
|
return tree
|
|||
|
|
finally:
|
|||
|
|
# Restore env so test runners that reuse the process don't leak
|
|||
|
|
# state across invocations.
|
|||
|
|
for k, v in prior_env.items():
|
|||
|
|
if v is None:
|
|||
|
|
os.environ.pop(k, None)
|
|||
|
|
else:
|
|||
|
|
os.environ[k] = v
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Phase 11: finalize
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _swap_dir_atomic(target: Path, staging: Path) -> None:
|
|||
|
|
"""Per-file overwrite from `staging` into `target` — preserves any
|
|||
|
|
unrelated sibling content already present in `target`.
|
|||
|
|
|
|||
|
|
P0 fix (2026-05-05): the prior implementation did
|
|||
|
|
``os.replace(target, backup); os.replace(staging, target)`` — an
|
|||
|
|
atomic whole-directory swap that was correct in isolation but
|
|||
|
|
DESTRUCTIVE of co-tenants. The `<agent>__<ver>/` dir is co-tenanted:
|
|||
|
|
co-tenants write per-session subdirs
|
|||
|
|
(`<session_id>/`) alongside architecture's files. Swapping the whole
|
|||
|
|
directory silently wiped every cached session trace.
|
|||
|
|
|
|||
|
|
New behavior: iterate `staging`'s immediate children and `os.replace`
|
|||
|
|
each one into `target`. The directory `target` itself is never
|
|||
|
|
moved or deleted, so any session subdirs under it survive.
|
|||
|
|
|
|||
|
|
Trade-off: the old helper supported atomic rollback (restore from
|
|||
|
|
backup on failure). The per-file pattern cannot — a crash partway
|
|||
|
|
through the loop leaves architecture's output in a split state
|
|||
|
|
(some new files, some old). This is acceptable because:
|
|||
|
|
- per-file `os.replace` is still atomic (no empty-path window
|
|||
|
|
for any individual file, so the "concurrent reader sees
|
|||
|
|
empty dir" bug that addressed is still closed);
|
|||
|
|
- `last_built_at.txt` is written LAST by the caller, so
|
|||
|
|
consumers can treat its presence as the success sentinel;
|
|||
|
|
- architecture is re-runnable, co-tenant session traces are not.
|
|||
|
|
|
|||
|
|
Callers must still guarantee `staging` is on the same filesystem as
|
|||
|
|
`target` (helper constructs `staging` as a sibling of `target`, so
|
|||
|
|
that holds by construction).
|
|||
|
|
"""
|
|||
|
|
import shutil
|
|||
|
|
|
|||
|
|
if not staging.is_dir():
|
|||
|
|
raise OSError(f"staging dir missing: {staging}")
|
|||
|
|
|
|||
|
|
target.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
for src in staging.iterdir():
|
|||
|
|
dst = target / src.name
|
|||
|
|
# Clear any existing destination so `os.replace` lands cleanly
|
|||
|
|
# when the incoming type differs (file replacing dir or vice
|
|||
|
|
# versa). For same-type overwrites `os.replace` would handle
|
|||
|
|
# files on its own, but directories require an empty target or
|
|||
|
|
# same-name existing dir — safer to unify both here.
|
|||
|
|
if dst.exists() or dst.is_symlink():
|
|||
|
|
if dst.is_dir() and not dst.is_symlink():
|
|||
|
|
shutil.rmtree(dst)
|
|||
|
|
else:
|
|||
|
|
dst.unlink()
|
|||
|
|
os.replace(src, dst)
|
|||
|
|
|
|||
|
|
# Staging should be empty now; best-effort teardown.
|
|||
|
|
try:
|
|||
|
|
staging.rmdir()
|
|||
|
|
except OSError:
|
|||
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_finalize(
|
|||
|
|
data_dir: Path,
|
|||
|
|
cache_dir: Path,
|
|||
|
|
tree: dict,
|
|||
|
|
work_dir: Path,
|
|||
|
|
agent_api_name: str,
|
|||
|
|
agent_version: str,
|
|||
|
|
planner_name: str,
|
|||
|
|
) -> None:
|
|||
|
|
"""Atomic-write tree + summary + manifest. Keep it simple.
|
|||
|
|
|
|||
|
|
For Phase 2 Batch 1 we replicate finalize.py's essential behavior
|
|||
|
|
(tree + manifest) but skip the sf_meta mirror — there's no
|
|||
|
|
sf_meta dir when main.py runs the pipeline SOQL-first. Metadata-API
|
|||
|
|
retrieves land in Batch 2/3.
|
|||
|
|
|
|||
|
|
both `data_dir` and `cache_dir` are swapped
|
|||
|
|
via `_swap_dir_atomic` to close the "rmtree then rename" empty-dir
|
|||
|
|
window. A crash during the second swap restores the original from
|
|||
|
|
the staging-sibling `.<name>.backup.<pid>` rather than leaving an
|
|||
|
|
empty path.
|
|||
|
|
"""
|
|||
|
|
import datetime as dt
|
|||
|
|
import shutil
|
|||
|
|
|
|||
|
|
built_at = dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|||
|
|
tree_base = f"{agent_api_name}_{agent_version}_metadata_tree"
|
|||
|
|
|
|||
|
|
# Finalize's _partial computation. Mirrors finalize.py:109-117 — both
|
|||
|
|
# buckets (_pending_fetches AND _unresolved) must be empty for a
|
|||
|
|
# clean run. _unresolved captures wave-B failures (HTTP 4xx,
|
|||
|
|
# iteration-cap exhaustion, managed-flow filter misses) that have
|
|||
|
|
# already been moved out of _pending_fetches; checking only the
|
|||
|
|
# latter would silently call a partial run "converged".
|
|||
|
|
planner_ok = bool(planner_name)
|
|||
|
|
pending_total = sum(len(v) for v in (tree.get("_pending_fetches") or {}).values())
|
|||
|
|
unresolved_count = len(tree.get("_unresolved") or [])
|
|||
|
|
waves_converged = pending_total == 0 and unresolved_count == 0
|
|||
|
|
tree["_partial"] = not (planner_ok and waves_converged)
|
|||
|
|
tree.pop("_visited", None)
|
|||
|
|
|
|||
|
|
# Pin deterministic child ordering as the last assembly step before
|
|||
|
|
# the authoritative write. Shared with the standalone `finalize.py`
|
|||
|
|
# subprocess so both paths produce byte-identical trees. Downstream
|
|||
|
|
# readers see one canonical order from disk; they do not re-sort.
|
|||
|
|
from finalize import sort_tree_in_place
|
|||
|
|
sort_tree_in_place(tree.get("root") or {})
|
|||
|
|
|
|||
|
|
# staging names are siblings (same parent) so the
|
|||
|
|
# `os.replace` used by `_swap_dir_atomic` is a same-filesystem atomic
|
|||
|
|
# rename. `.staging.<pid>` disambiguates concurrent pipelines on the
|
|||
|
|
# same host.
|
|||
|
|
data_parent = data_dir.parent
|
|||
|
|
data_staging = data_parent / f".{data_dir.name}.staging.{os.getpid()}"
|
|||
|
|
if data_staging.exists():
|
|||
|
|
shutil.rmtree(data_staging)
|
|||
|
|
data_staging.mkdir(parents=True)
|
|||
|
|
tree_out = data_staging / f"{tree_base}.json"
|
|||
|
|
tree_out.write_text(json.dumps(tree, indent=2))
|
|||
|
|
|
|||
|
|
# P2.2-1: phase 10 (architecture.md renderer). Imported lazily so a
|
|||
|
|
# renderer bug can't break the existing Batch 1 pipeline before the
|
|||
|
|
# tree JSON has been written — the tree + manifest remain the
|
|||
|
|
# authoritative outputs, architecture.md is a derived artifact.
|
|||
|
|
#
|
|||
|
|
# filename now self-identifying —
|
|||
|
|
# `{agent_api_name}_{agent_version}_architecture.md` (e.g.
|
|||
|
|
# `MyAgent_v5_architecture.md`). Same shape as the tree JSON,
|
|||
|
|
# minus the `_metadata_tree` suffix. The `.error` sidecar follows
|
|||
|
|
# the same shape.
|
|||
|
|
arch_base = f"{agent_api_name}_{agent_version}"
|
|||
|
|
arch_name = f"{arch_base}_architecture.md"
|
|||
|
|
arch_error_name = f"{arch_base}_architecture.md.error"
|
|||
|
|
try:
|
|||
|
|
from render_architecture import render as _render_architecture
|
|||
|
|
_render_architecture(tree_out, data_staging / arch_name)
|
|||
|
|
except Exception as e:
|
|||
|
|
# Best-effort: a render failure downgrades the run to "tree OK,
|
|||
|
|
# architecture skipped" rather than aborting finalize. The
|
|||
|
|
# reason string surfaces in the next-run logs for triage.
|
|||
|
|
(data_staging / arch_error_name).write_text(
|
|||
|
|
f"render_architecture failed: {type(e).__name__}: {e}\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# dropped `{tree_base}.summary.md` from the output contract —
|
|||
|
|
# was a redundant summary of the tree JSON; consumers should read the
|
|||
|
|
# JSON directly. See CHANGELOG for details.
|
|||
|
|
(data_staging / "last_built_at.txt").write_text(built_at + "\n")
|
|||
|
|
|
|||
|
|
_swap_dir_atomic(data_dir, data_staging)
|
|||
|
|
|
|||
|
|
# CACHE_DIR staging — same two-phase swap.
|
|||
|
|
cache_parent = cache_dir.parent
|
|||
|
|
cache_staging = cache_parent / f".{cache_dir.name}.staging.{os.getpid()}"
|
|||
|
|
if cache_staging.exists():
|
|||
|
|
shutil.rmtree(cache_staging)
|
|||
|
|
cache_staging.mkdir(parents=True)
|
|||
|
|
|
|||
|
|
manifest = {
|
|||
|
|
"built_at_utc": built_at,
|
|||
|
|
"schema_version": tree.get("_schema_version", SCHEMA_VERSION),
|
|||
|
|
"agent": tree.get("agent", {}),
|
|||
|
|
"node_count": tree.get("node_count", 0),
|
|||
|
|
"depth": tree.get("depth", 0),
|
|||
|
|
"kind_counts": tree.get("_kind_counts", {}),
|
|||
|
|
"ttl_days": CACHE_TTL_DAYS,
|
|||
|
|
"data_path": str(data_dir / f"{tree_base}.json"),
|
|||
|
|
"partial": tree.get("_partial", False),
|
|||
|
|
"unresolved_count": len(tree.get("_unresolved", []) or []),
|
|||
|
|
}
|
|||
|
|
(cache_staging / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
|||
|
|
# Copy tree + bundle sidecar for cache replay
|
|||
|
|
(cache_staging / "declared_action_tree.json").write_text(json.dumps(tree, indent=2))
|
|||
|
|
bundle_src = work_dir / "_bundle_parsed.json"
|
|||
|
|
if bundle_src.is_file():
|
|||
|
|
(cache_staging / "_bundle_parsed.json").write_text(bundle_src.read_text())
|
|||
|
|
|
|||
|
|
_swap_dir_atomic(cache_dir, cache_staging)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Emit helpers
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _read_built_at_utc(cache_dir: Path | None) -> str:
|
|||
|
|
if cache_dir is None:
|
|||
|
|
return ""
|
|||
|
|
try:
|
|||
|
|
return json.loads((cache_dir / "manifest.json").read_text()).get("built_at_utc", "") or ""
|
|||
|
|
except (OSError, ValueError):
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _emit_fail(
|
|||
|
|
args: argparse.Namespace,
|
|||
|
|
extra_ctx: dict | None,
|
|||
|
|
status: str,
|
|||
|
|
error_detail: str = "",
|
|||
|
|
start_epoch: float | None = None,
|
|||
|
|
) -> int:
|
|||
|
|
"""Write an .emit_ctx.json with STATUS=<status>.
|
|||
|
|
|
|||
|
|
STATUS enum (from emit_result.py):
|
|||
|
|
OK, PARTIAL_OK, INVALID_INPUT, AUTH_REQUIRED, AGENT_NOT_FOUND,
|
|||
|
|
AGENT_VERSION_NOT_FOUND, RETRIEVE_FAILED, WRITE_FAILED.
|
|||
|
|
|
|||
|
|
For probe failures, the plan's description suggested SCHEMA_DRIFT
|
|||
|
|
but emit_result doesn't carry that enum value. We map probe failures
|
|||
|
|
to RETRIEVE_FAILED with an explicit error_detail — the RESULT block's
|
|||
|
|
ERROR_DETAIL carries the "schema-drift" reason. Adding a new enum
|
|||
|
|
value would require touching emit_result (out of scope P1 primitive);
|
|||
|
|
the mapping preserves emit_result's existing contract.
|
|||
|
|
|
|||
|
|
Returns the exit code the caller should return.
|
|||
|
|
"""
|
|||
|
|
work_dir = Path(args.work_dir)
|
|||
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
ctx = {
|
|||
|
|
"status": status,
|
|||
|
|
"error_detail": error_detail,
|
|||
|
|
"agent_api_name": args.agent_api_name,
|
|||
|
|
"agent_version": args.agent_version or "",
|
|||
|
|
"version_auto_picked": False,
|
|||
|
|
"agent_generation": "unknown",
|
|||
|
|
"bot_id": "",
|
|||
|
|
"org_id_15": "",
|
|||
|
|
"org_id_18": "",
|
|||
|
|
"cache_hit": False,
|
|||
|
|
"cached_at_utc": "",
|
|||
|
|
"cache_path": "",
|
|||
|
|
"output_json_path": "",
|
|||
|
|
"output_summary_path": "",
|
|||
|
|
"node_count": 0,
|
|||
|
|
"depth": 0,
|
|||
|
|
"partial": status == "PARTIAL_OK",
|
|||
|
|
"partial_reason": "",
|
|||
|
|
"pending_fetches_count": 0,
|
|||
|
|
"unresolved_count": 0,
|
|||
|
|
"available_bots": "",
|
|||
|
|
"available_versions": "",
|
|||
|
|
# Bug fix (2026-05-05): start_epoch captured once at pipeline entry
|
|||
|
|
# and threaded through every _emit_* helper. Previously each helper
|
|||
|
|
# re-captured time.time() AFTER the pipeline had already run, so
|
|||
|
|
# emit_result.py computed WALL_TIME_SECONDS against a timestamp taken
|
|||
|
|
# ~tens of seconds AFTER the real work finished and reported ~0.0s.
|
|||
|
|
"start_epoch": start_epoch if start_epoch is not None else time.time(),
|
|||
|
|
"data_dir": str(DATA_ROOT),
|
|||
|
|
"work_dir": str(work_dir),
|
|||
|
|
# failure paths never invoke the renderer.
|
|||
|
|
"architecture_path": "",
|
|||
|
|
"render_failed": False,
|
|||
|
|
"render_error_detail": "",
|
|||
|
|
}
|
|||
|
|
if extra_ctx:
|
|||
|
|
ctx.update(extra_ctx)
|
|||
|
|
(work_dir / ".emit_ctx.json").write_text(json.dumps(ctx, indent=2))
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _emit_cache_hit(
|
|||
|
|
args: argparse.Namespace,
|
|||
|
|
data_dir: Path,
|
|||
|
|
cache_dir: Path,
|
|||
|
|
manifest: dict,
|
|||
|
|
start_epoch: float | None = None,
|
|||
|
|
*,
|
|||
|
|
org_id_15: str = "",
|
|||
|
|
org_id_18: str = "",
|
|||
|
|
) -> int:
|
|||
|
|
work_dir = Path(args.work_dir)
|
|||
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
agent_version = manifest.get("agent", {}).get("version", "")
|
|||
|
|
tree_base = f"{args.agent_api_name}_{agent_version}_metadata_tree"
|
|||
|
|
|
|||
|
|
# on a cache hit the renderer is NOT re-invoked — but the
|
|||
|
|
# architecture.md from the prior run is a legitimate output of the
|
|||
|
|
# cached data_dir. Surface it when present, skip the .error sidecar
|
|||
|
|
# since render_failed semantics only apply to THIS run's renderer.
|
|||
|
|
# filename rename to `{agent}_{ver}_architecture.md` —
|
|||
|
|
# NB `tree_base` above still carries the `_metadata_tree` suffix (it
|
|||
|
|
# names the JSON); architecture filenames drop the suffix.
|
|||
|
|
arch_base = f"{args.agent_api_name}_{agent_version}"
|
|||
|
|
arch_path = data_dir / f"{arch_base}_architecture.md"
|
|||
|
|
architecture_path = str(arch_path) if arch_path.is_file() else ""
|
|||
|
|
|
|||
|
|
ctx = {
|
|||
|
|
"status": "OK",
|
|||
|
|
"error_detail": "",
|
|||
|
|
"agent_api_name": args.agent_api_name,
|
|||
|
|
"agent_version": manifest.get("agent", {}).get("version", ""),
|
|||
|
|
"version_auto_picked": manifest.get("agent", {}).get("_version_auto_picked", False),
|
|||
|
|
"agent_generation": manifest.get("agent", {}).get("generation", "unknown"),
|
|||
|
|
"bot_id": manifest.get("agent", {}).get("bot_id", ""),
|
|||
|
|
"org_id_15": org_id_15,
|
|||
|
|
"org_id_18": org_id_18,
|
|||
|
|
"cache_hit": True,
|
|||
|
|
"cached_at_utc": manifest.get("built_at_utc", ""),
|
|||
|
|
"cache_path": str(cache_dir),
|
|||
|
|
"output_json_path": str(data_dir / f"{tree_base}.json"),
|
|||
|
|
# summary.md dropped from the output contract. Field kept
|
|||
|
|
# empty for RESULT-block shape stability.
|
|||
|
|
"output_summary_path": "",
|
|||
|
|
"node_count": int(manifest.get("node_count", 0) or 0),
|
|||
|
|
"depth": int(manifest.get("depth", 0) or 0),
|
|||
|
|
"partial": bool(manifest.get("partial", False)),
|
|||
|
|
"partial_reason": "",
|
|||
|
|
"pending_fetches_count": 0,
|
|||
|
|
"unresolved_count": int(manifest.get("unresolved_count", 0) or 0),
|
|||
|
|
"available_bots": "",
|
|||
|
|
"available_versions": "",
|
|||
|
|
# Bug fix (2026-05-05): see _emit_fail note — start_epoch is captured
|
|||
|
|
# at pipeline entry and threaded through. Cache-hit paths are short-
|
|||
|
|
# running so the drift is smaller, but the field should still reflect
|
|||
|
|
# the invocation's real wall time.
|
|||
|
|
"start_epoch": start_epoch if start_epoch is not None else time.time(),
|
|||
|
|
"data_dir": str(data_dir),
|
|||
|
|
"work_dir": str(work_dir),
|
|||
|
|
# architecture-render outcome signals. render_failed
|
|||
|
|
# stays False for cache replays — that flag tracks THIS run's
|
|||
|
|
# renderer, not the cached run's; a cache hit implies no retry.
|
|||
|
|
"architecture_path": architecture_path,
|
|||
|
|
"render_failed": False,
|
|||
|
|
"render_error_detail": "",
|
|||
|
|
}
|
|||
|
|
(work_dir / ".emit_ctx.json").write_text(json.dumps(ctx, indent=2))
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _emit_ok(
|
|||
|
|
args: argparse.Namespace,
|
|||
|
|
data_dir: Path,
|
|||
|
|
tree: dict,
|
|||
|
|
bot_info: dict,
|
|||
|
|
org_id_15: str,
|
|||
|
|
start_epoch: float | None = None,
|
|||
|
|
cache_dir: Path | None = None,
|
|||
|
|
org_id_18: str = "",
|
|||
|
|
) -> int:
|
|||
|
|
work_dir = Path(args.work_dir)
|
|||
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
agent_version = bot_info["version"]
|
|||
|
|
tree_base = f"{args.agent_api_name}_{agent_version}_metadata_tree"
|
|||
|
|
pending_total = sum(len(v) for v in (tree.get("_pending_fetches") or {}).values())
|
|||
|
|
|
|||
|
|
# surface architecture-render outcome on the success path.
|
|||
|
|
# finalize writes either `{agent}_{ver}_architecture.md` (render OK) or
|
|||
|
|
# `{agent}_{ver}_architecture.md.error` (render raised) into the
|
|||
|
|
# data_dir; we read whichever is present and plumb the fields through
|
|||
|
|
# the ctx. emit_result consumes the boolean to auto-promote
|
|||
|
|
# OK -> PARTIAL_OK when the tree landed but the diagram render failed.
|
|||
|
|
# filename rename — self-identifying shape. `tree_base` above
|
|||
|
|
# is for the JSON path (carries `_metadata_tree`); architecture uses
|
|||
|
|
# the unsuffixed agent+version base.
|
|||
|
|
arch_base = f"{args.agent_api_name}_{agent_version}"
|
|||
|
|
arch_path = data_dir / f"{arch_base}_architecture.md"
|
|||
|
|
arch_sidecar = data_dir / f"{arch_base}_architecture.md.error"
|
|||
|
|
architecture_path = str(arch_path) if arch_path.is_file() else ""
|
|||
|
|
render_failed = arch_sidecar.is_file()
|
|||
|
|
render_error_detail = ""
|
|||
|
|
if render_failed:
|
|||
|
|
try:
|
|||
|
|
raw = arch_sidecar.read_text(errors="replace").strip()
|
|||
|
|
except OSError:
|
|||
|
|
raw = ""
|
|||
|
|
if raw:
|
|||
|
|
# Single-line cap, 200-char truncation, scrubbed via the
|
|||
|
|
# rest_client redactor (same contract as write_emit_ctx).
|
|||
|
|
from rest_client import redact_text as _redact
|
|||
|
|
render_error_detail = _redact(raw.splitlines()[0])[:200]
|
|||
|
|
|
|||
|
|
ctx = {
|
|||
|
|
"status": "PARTIAL_OK" if tree.get("_partial") else "OK",
|
|||
|
|
"error_detail": "",
|
|||
|
|
"agent_api_name": args.agent_api_name,
|
|||
|
|
"agent_version": bot_info["version"],
|
|||
|
|
"version_auto_picked": bot_info["version_auto_picked"],
|
|||
|
|
"agent_generation": tree.get("agent", {}).get("generation", "unknown"),
|
|||
|
|
"bot_id": bot_info["bot_id"],
|
|||
|
|
"org_id_15": org_id_15,
|
|||
|
|
# Gap 3 fix (2026-05-05): the 18-char id is available from
|
|||
|
|
# `sf org display` in _derive_org_ids but was previously dropped.
|
|||
|
|
# Now threaded from pipeline entry so the RESULT contract field is
|
|||
|
|
# actually populated on success.
|
|||
|
|
"org_id_18": org_id_18,
|
|||
|
|
"cache_hit": False,
|
|||
|
|
"cached_at_utc": _read_built_at_utc(cache_dir),
|
|||
|
|
# Gap 4 fix (2026-05-05): non-cache-hit runs write the manifest into
|
|||
|
|
# `cache_dir` (see _run_finalize) so the field should point at the
|
|||
|
|
# same path a subsequent invocation would probe. Caller passes the
|
|||
|
|
# already-computed cache_dir; we skip the leading-empty fallback
|
|||
|
|
# only when it's truly unknown (shouldn't happen on _emit_ok).
|
|||
|
|
"cache_path": str(cache_dir) if cache_dir is not None else "",
|
|||
|
|
"output_json_path": str(data_dir / f"{tree_base}.json"),
|
|||
|
|
# summary.md dropped from the output contract.
|
|||
|
|
"output_summary_path": "",
|
|||
|
|
"node_count": int(tree.get("node_count", 0) or 0),
|
|||
|
|
"depth": int(tree.get("depth", 0) or 0),
|
|||
|
|
"partial": bool(tree.get("_partial", False)),
|
|||
|
|
"partial_reason": tree.get("_partial_reason") or "",
|
|||
|
|
"pending_fetches_count": pending_total,
|
|||
|
|
"unresolved_count": len(tree.get("_unresolved", []) or []),
|
|||
|
|
"available_bots": "",
|
|||
|
|
"available_versions": "",
|
|||
|
|
# Bug fix (2026-05-05): see _emit_fail note — start_epoch threaded
|
|||
|
|
# from pipeline entry so WALL_TIME_SECONDS reflects the full run.
|
|||
|
|
"start_epoch": start_epoch if start_epoch is not None else time.time(),
|
|||
|
|
"data_dir": str(data_dir),
|
|||
|
|
"work_dir": str(work_dir),
|
|||
|
|
# architecture-render outcome signals.
|
|||
|
|
"architecture_path": architecture_path,
|
|||
|
|
"render_failed": render_failed,
|
|||
|
|
"render_error_detail": render_error_detail,
|
|||
|
|
}
|
|||
|
|
(work_dir / ".emit_ctx.json").write_text(json.dumps(ctx, indent=2))
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Entry point
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _run_pipeline(args: argparse.Namespace, start_epoch: float) -> int: # noqa: PLR0911, PLR0915
|
|||
|
|
"""Execute the 12-phase pipeline. Extracted from `main()` so the
|
|||
|
|
top-level entry point can wrap any uncaught exception in a RESULT
|
|||
|
|
block .
|
|||
|
|
|
|||
|
|
Every structured failure path here already routes through
|
|||
|
|
`_emit_fail` and returns a non-zero exit code. Exceptions that
|
|||
|
|
escape this function are, by definition, bugs we hadn't thought of
|
|||
|
|
yet — `main()` catches them so the skill contract (RESULT block on
|
|||
|
|
every exit path) holds regardless.
|
|||
|
|
"""
|
|||
|
|
work_dir = Path(args.work_dir)
|
|||
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
# Phase 2 Batch 1: single creds cell shared between provider + refresh.
|
|||
|
|
# Refresh mutates the cell; provider reads it on every attempt — this
|
|||
|
|
# is the `` refresh contract.
|
|||
|
|
#
|
|||
|
|
# Wave B fetches run N concurrent Flow.Metadata
|
|||
|
|
# calls through parallel_retrieve.fetch_bodies_parallel. `refresh_fn`
|
|||
|
|
# serializes behind a lock + 1-second monotonic dedupe window so
|
|||
|
|
# concurrent 401s don't each fork their own `sf org display`. See
|
|||
|
|
# `_build_creds_plumbing`.
|
|||
|
|
try:
|
|||
|
|
initial = _resolve_creds(args.org_alias)
|
|||
|
|
except (AuthRequired, SfCliError) as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "AUTH_REQUIRED",
|
|||
|
|
error_detail=redact_error(e),
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
creds_provider, refresh_fn, _creds_cell = _build_creds_plumbing(
|
|||
|
|
initial,
|
|||
|
|
resolve_creds=lambda: _resolve_creds(args.org_alias),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 2.5: derive org_id_15 + org_id_18 + api_version
|
|||
|
|
try:
|
|||
|
|
org_id_15, org_id_18, api_version = _derive_org_ids(args.org_alias)
|
|||
|
|
except (AuthRequired, SfCliError) as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "AUTH_REQUIRED",
|
|||
|
|
error_detail=redact_error(e),
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 3: probe channels
|
|||
|
|
channels = probe_channels(
|
|||
|
|
args.org_alias, org_id_15, api_version,
|
|||
|
|
force_refresh=args.reprobe,
|
|||
|
|
)
|
|||
|
|
if channels.get("status") == "PROBE_FAILED":
|
|||
|
|
# Plan suggested STATUS=SCHEMA_DRIFT; emit_result's STATUS enum
|
|||
|
|
# doesn't include that value. Mapping to RETRIEVE_FAILED with a
|
|||
|
|
# "schema-drift" ERROR_DETAIL preserves the existing enum
|
|||
|
|
# contract while surfacing the cause. A future batch may add
|
|||
|
|
# SCHEMA_DRIFT to the enum; that change lives in emit_result.
|
|||
|
|
missing = [
|
|||
|
|
(sobj, e.get("mandatory_missing", []))
|
|||
|
|
for sobj, e in (channels.get("channels") or {}).items()
|
|||
|
|
if e.get("mandatory_missing")
|
|||
|
|
]
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "RETRIEVE_FAILED",
|
|||
|
|
error_detail=f"schema-drift: mandatory fields missing: {missing[:3]}",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 4: resolve bot + version
|
|||
|
|
try:
|
|||
|
|
bot_info = _resolve_bot(
|
|||
|
|
args.agent_api_name, args.agent_version,
|
|||
|
|
creds_provider, refresh_fn,
|
|||
|
|
api_version=api_version,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError) as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "AUTH_REQUIRED",
|
|||
|
|
error_detail=redact_error(e),
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if bot_info is None:
|
|||
|
|
# AGENT_NOT_FOUND — emit without a list-of-available-bots context.
|
|||
|
|
# The unbounded bot-list SELECT was removed (LIMIT-less unfiltered
|
|||
|
|
# query on BotDefinition). Operators can run their own SOQL if
|
|||
|
|
# they need to enumerate available bots.
|
|||
|
|
return _emit_fail(
|
|||
|
|
args,
|
|||
|
|
{"org_id_15": org_id_15},
|
|||
|
|
"AGENT_NOT_FOUND",
|
|||
|
|
error_detail=f"BotDefinition.DeveloperName '{args.agent_api_name}' not found",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if bot_info.get("_version_not_found"):
|
|||
|
|
vers_csv = ",".join(
|
|||
|
|
f"{v['version']}({v['status']})" for v in bot_info.get("all_versions", [])
|
|||
|
|
)
|
|||
|
|
return _emit_fail(
|
|||
|
|
args,
|
|||
|
|
{"available_versions": vers_csv, "org_id_15": org_id_15},
|
|||
|
|
"AGENT_VERSION_NOT_FOUND",
|
|||
|
|
error_detail=f"No matching BotVersion (requested: {args.agent_version!r})",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 5: cache check
|
|||
|
|
data_dir = build_agent_data_dir(org_id_15, args.agent_api_name, bot_info["version"])
|
|||
|
|
cache_dir = build_agent_cache_dir(org_id_15, args.agent_api_name, bot_info["version"])
|
|||
|
|
if not args.force_refresh:
|
|||
|
|
manifest = _cache_is_fresh(cache_dir)
|
|||
|
|
if manifest is not None:
|
|||
|
|
return _emit_cache_hit(
|
|||
|
|
args, data_dir, cache_dir, manifest,
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
org_id_15=org_id_15,
|
|||
|
|
org_id_18=org_id_18,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 6: Wave A — 7 queries, DAG-ordered parallel layers. A1 now
|
|||
|
|
# takes (agent_api_name, version) and resolves the canonical planner
|
|||
|
|
# DeveloperName from the live chain naming invariant (v1 = <Agent>,
|
|||
|
|
# vN = <Agent>...%\_vN); the row's DeveloperName becomes the
|
|||
|
|
# authoritative `planner_name` for finalize/render downstream.
|
|||
|
|
try:
|
|||
|
|
wave_a = _run_wave_a(
|
|||
|
|
args.agent_api_name, bot_info["version"],
|
|||
|
|
creds_provider, refresh_fn,
|
|||
|
|
api_version=api_version,
|
|||
|
|
parallelism=args.parallelism,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError) as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "RETRIEVE_FAILED",
|
|||
|
|
error_detail=f"wave-a-failed:{redact_error(e)}",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if wave_a is None:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, {"org_id_15": org_id_15},
|
|||
|
|
"AGENT_NOT_FOUND",
|
|||
|
|
error_detail=(
|
|||
|
|
f"GenAiPlannerDefinition chain not found for "
|
|||
|
|
f"agent={args.agent_api_name!r} version={bot_info['version']!r}"
|
|||
|
|
),
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 7: join Wave A → bundle-shaped dict
|
|||
|
|
bundle_parsed = _join_wave_a_to_bundle(bot_info, wave_a)
|
|||
|
|
|
|||
|
|
# Phase 8: Wave B — body fetches
|
|||
|
|
try:
|
|||
|
|
wave_b = _run_wave_b(
|
|||
|
|
bundle_parsed,
|
|||
|
|
creds_provider, refresh_fn,
|
|||
|
|
api_version=api_version,
|
|||
|
|
org_alias=args.org_alias,
|
|||
|
|
parallelism=args.parallelism,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError) as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "RETRIEVE_FAILED",
|
|||
|
|
error_detail=f"wave-b-failed:{redact_error(e)}",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 8.5: iterative Wave B — follow subflow + apex refs from fetched
|
|||
|
|
# Flow.Metadata bodies until fixed-point. Without this pass, shared
|
|||
|
|
# utility flows (handleFlowFault) and nested subflows ship as empty
|
|||
|
|
# leaves even though their bodies are reachable via Tooling API.
|
|||
|
|
# Iteration cap safeguards against pathological flow graphs.
|
|||
|
|
try:
|
|||
|
|
wave_b = _iterate_wave_b(
|
|||
|
|
wave_b,
|
|||
|
|
creds_provider, refresh_fn,
|
|||
|
|
api_version=api_version,
|
|||
|
|
org_alias=args.org_alias,
|
|||
|
|
parallelism=args.parallelism,
|
|||
|
|
max_iterations=5,
|
|||
|
|
)
|
|||
|
|
except (RestClientError, SoqlParamError) as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "RETRIEVE_FAILED",
|
|||
|
|
error_detail=f"wave-b-iter-failed:{redact_error(e)}",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Surface Wave-A layer-2/3 task failures alongside Wave-B's so the
|
|||
|
|
# downstream `_run_parse_wave` sweep folds them into `tree["_unresolved"]`
|
|||
|
|
# and `_partial_reason` flips to PARTIAL_OK.
|
|||
|
|
wave_a_unresolved = wave_a.get("unresolved") or []
|
|||
|
|
if wave_a_unresolved:
|
|||
|
|
wave_b["unresolved"] = [*wave_a_unresolved, *(wave_b.get("unresolved") or [])]
|
|||
|
|
|
|||
|
|
# normalize Flow-ID InvocationTargets → DeveloperName.
|
|||
|
|
# Classic ReAct bots occasionally store NGA-style 300Uv-prefix Flow IDs
|
|||
|
|
# in GenAiFunctionDefinition.InvocationTarget (observed in one real-org
|
|||
|
|
# benchmark run, 6 flows out of 6). Wave B's fetch_flow_definition_by_ids
|
|||
|
|
# successfully resolves them, but the resolved DeveloperName never
|
|||
|
|
# propagates back into bundle_parsed — so parse_wave sees the raw ID as
|
|||
|
|
# the flow's api_name, which never matches visited["FLOW"] (keyed on
|
|||
|
|
# DeveloperName), and every such flow ends up in _pending_fetches.
|
|||
|
|
# Result: PARTIAL_OK on what should be OK, plus unreadable Flow IDs in
|
|||
|
|
# rendered architecture.md.
|
|||
|
|
#
|
|||
|
|
# Fix: rewrite bundle_parsed's action invocationTarget in-place from
|
|||
|
|
# ID → DeveloperName using wave_b["flow_def_rows"] (both the IN-by-names
|
|||
|
|
# and the IN-by-ids branches populate it). Unresolved IDs (Flow not
|
|||
|
|
# found in the org) stay as-is and will land in _pending_fetches as
|
|||
|
|
# intended (they really are unresolved).
|
|||
|
|
_normalize_flow_id_targets(bundle_parsed, wave_b["flow_def_rows"])
|
|||
|
|
|
|||
|
|
# Bug 1 fix (2026-05-05): analogous rewrite for GenAiPromptTemplate Ids.
|
|||
|
|
# Wave B's prompt-template Id fetch resolves 0hf-prefix Ids to
|
|||
|
|
# DeveloperNames; without this rewrite the bundle would enqueue the
|
|||
|
|
# raw Id into _pending_fetches.PROMPT_TEMPLATE and the downstream
|
|||
|
|
# Metadata retrieve would never match.
|
|||
|
|
_normalize_prompt_template_id_targets(
|
|||
|
|
bundle_parsed, wave_b.get("prompt_template_id_rows") or [],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Gap B fix (2026-05-05): analogous rewrite for ApexClass Ids.
|
|||
|
|
# Wave B's fetch_apex_bodies_by_ids resolves 01p-prefix Ids to ApexClass
|
|||
|
|
# Name; without this rewrite the bundle would enqueue the raw Id into
|
|||
|
|
# _pending_fetches.APEX and the tree would render the bare Id instead
|
|||
|
|
# of the class name.
|
|||
|
|
_normalize_apex_id_targets(
|
|||
|
|
bundle_parsed, wave_b.get("apex_rows") or [],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Gap C fix (2026-05-05): retrieve GenAiPromptTemplate bodies.
|
|||
|
|
# GenAiPromptTemplate is NOT SOQL-queryable (Tooling or Data API). The
|
|||
|
|
# only way to fetch the body is `sf project retrieve start
|
|||
|
|
# --metadata GenAiPromptTemplate:<name>,...`. After
|
|||
|
|
# _normalize_prompt_template_id_targets has rewritten 0hf-Ids to
|
|||
|
|
# DeveloperNames, collect the names and retrieve in a single sf
|
|||
|
|
# subprocess call. The resulting bodies get stashed on wave_b so
|
|||
|
|
# _run_parse_wave can stamp them onto PROMPT_TEMPLATE leaves (parallel
|
|||
|
|
# to the Apex/Flow signature stamping pattern).
|
|||
|
|
prompt_template_names = _collect_prompt_template_names(bundle_parsed)
|
|||
|
|
prompt_template_bodies: dict[str, dict] = {}
|
|||
|
|
if prompt_template_names:
|
|||
|
|
try:
|
|||
|
|
prompt_template_bodies = retrieve_prompt_templates(
|
|||
|
|
args.org_alias,
|
|||
|
|
sorted(prompt_template_names),
|
|||
|
|
work_dir,
|
|||
|
|
)
|
|||
|
|
except AuthRequired:
|
|||
|
|
raise
|
|||
|
|
except SfCliError as e:
|
|||
|
|
wave_b.setdefault("unresolved", []).append({
|
|||
|
|
"kind": "PROMPT_TEMPLATE",
|
|||
|
|
"reason": f"prompt-template-retrieve-failed:{redact_error(e)}",
|
|||
|
|
})
|
|||
|
|
wave_b["prompt_template_bodies"] = prompt_template_bodies
|
|||
|
|
|
|||
|
|
# Phase 9: parse_wave -> declared_action_tree.json
|
|||
|
|
try:
|
|||
|
|
tree = _run_parse_wave(bot_info, bundle_parsed, wave_b, args, work_dir)
|
|||
|
|
except (OSError, KeyError) as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "WRITE_FAILED",
|
|||
|
|
error_detail=f"parse-wave-failed:{redact_error(e)}",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 10: render architecture.md — invoked inline from _run_finalize
|
|||
|
|
# so the write lands in the same staging dir as the tree JSON and
|
|||
|
|
# participates in the atomic _swap_dir_atomic below. See P2.2-1.
|
|||
|
|
|
|||
|
|
# Flow-nested prompt-template catch-up (2026-05-05). The bundle-scoped
|
|||
|
|
# retrieve at step 1 above only covers prompt templates declared as
|
|||
|
|
# top-level topic/planner InvocationTargets. Templates referenced from
|
|||
|
|
# inside a Flow (via a `generatePromptResponse` actionCall) are
|
|||
|
|
# discovered during parse_wave's BFS of flow XML — too late for the
|
|||
|
|
# first retrieve. Without this second pass their `_body_available`
|
|||
|
|
# stays False and the md renders `_Body not retrieved._` with no
|
|||
|
|
# PARTIAL flag (silent content loss). `_stamp_prompt_template_bodies`
|
|||
|
|
# is idempotent, so re-stamping already-stamped leaves is safe.
|
|||
|
|
flow_nested_names: set[str] = set()
|
|||
|
|
_collect_flow_nested_prompt_template_names(
|
|||
|
|
tree["root"], prompt_template_bodies, flow_nested_names,
|
|||
|
|
)
|
|||
|
|
if flow_nested_names:
|
|||
|
|
try:
|
|||
|
|
more_bodies = retrieve_prompt_templates(
|
|||
|
|
args.org_alias,
|
|||
|
|
sorted(flow_nested_names),
|
|||
|
|
work_dir,
|
|||
|
|
)
|
|||
|
|
except AuthRequired:
|
|||
|
|
raise
|
|||
|
|
except SfCliError as e:
|
|||
|
|
tree.setdefault("_unresolved", []).append({
|
|||
|
|
"kind": "PROMPT_TEMPLATE",
|
|||
|
|
"reason": f"prompt-template-retrieve-failed:{redact_error(e)}",
|
|||
|
|
})
|
|||
|
|
else:
|
|||
|
|
prompt_template_bodies.update(more_bodies)
|
|||
|
|
_stamp_prompt_template_bodies(tree["root"], prompt_template_bodies)
|
|||
|
|
# _kind_counts / _pending_fetches are unchanged: the leaves
|
|||
|
|
# already existed and were counted; this pass only attaches
|
|||
|
|
# body content to them. No BFS-kind reshuffling needed.
|
|||
|
|
|
|||
|
|
# Phase 11: finalize. The canonical planner_name now comes from the
|
|||
|
|
# resolved planner row (Wave A), which joins into bundle_parsed as
|
|||
|
|
# `plannerName`; fall back to the agent api name if the resolver
|
|||
|
|
# returned a row without a DeveloperName (shouldn't happen on a
|
|||
|
|
# healthy planner but keeps finalize's _partial flag well-defined).
|
|||
|
|
planner_name = bundle_parsed.get("plannerName") or args.agent_api_name
|
|||
|
|
try:
|
|||
|
|
_run_finalize(
|
|||
|
|
data_dir, cache_dir, tree, work_dir,
|
|||
|
|
args.agent_api_name, bot_info["version"],
|
|||
|
|
planner_name,
|
|||
|
|
)
|
|||
|
|
except OSError as e:
|
|||
|
|
return _emit_fail(
|
|||
|
|
args, None, "WRITE_FAILED",
|
|||
|
|
error_detail=f"finalize-failed:{redact_error(e)}",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Phase 12: emit RESULT
|
|||
|
|
return _emit_ok(
|
|||
|
|
args, data_dir, tree, bot_info, org_id_15,
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
cache_dir=cache_dir,
|
|||
|
|
org_id_18=org_id_18,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main(argv: list[str] | None = None) -> int:
|
|||
|
|
"""Entry point — parses args, runs pipeline, guarantees a RESULT block.
|
|||
|
|
|
|||
|
|
the skill contract promises that every exit
|
|||
|
|
path emits `=== RESULT ===` with a structured STATUS. The prior
|
|||
|
|
implementation let any uncaught exception from the pipeline (e.g.
|
|||
|
|
the HTTP 405 from , or any future bug we haven't foreseen)
|
|||
|
|
propagate to the top of the process — the user saw a Python
|
|||
|
|
traceback on stderr and the wrapper skill never got its RESULT
|
|||
|
|
block. We now wrap `_run_pipeline` in a broad `except Exception` and
|
|||
|
|
funnel uncaught failures through `_emit_fail` with
|
|||
|
|
STATUS=RETRIEVE_FAILED + ERROR_DETAIL="uncaught-exception: <redacted
|
|||
|
|
message>". `redact_error` keeps tokens / full-URL querystrings out
|
|||
|
|
of the ctx file. `SystemExit` + `KeyboardInterrupt` are NOT caught —
|
|||
|
|
argparse's `--help` path + Ctrl-C must propagate normally.
|
|||
|
|
"""
|
|||
|
|
args = parse_args(argv)
|
|||
|
|
# MUST happen before any pipeline phase reads DATA_ROOT / CACHE_ROOT.
|
|||
|
|
# See _apply_path_overrides docstring for the 3-level rebind contract.
|
|||
|
|
_apply_path_overrides(args)
|
|||
|
|
# Bug fix (2026-05-05): capture start_epoch ONCE at real pipeline entry
|
|||
|
|
# and thread it through every _emit_* call so WALL_TIME_SECONDS measures
|
|||
|
|
# actual pipeline duration, not ctx-write→emit_result drift.
|
|||
|
|
start_epoch = time.time()
|
|||
|
|
try:
|
|||
|
|
return _run_pipeline(args, start_epoch)
|
|||
|
|
except Exception as exc:
|
|||
|
|
# broad catch — anything that reaches here is a bug
|
|||
|
|
# but the skill contract requires a structured exit. redact_error
|
|||
|
|
# strips bearer tokens and accessToken values from the message
|
|||
|
|
# ; the exception class name leads the detail so triage
|
|||
|
|
# can still identify the shape even when the message is terse.
|
|||
|
|
redacted = redact_error(exc)
|
|||
|
|
return _emit_fail(
|
|||
|
|
args,
|
|||
|
|
None,
|
|||
|
|
"RETRIEVE_FAILED",
|
|||
|
|
error_detail=f"uncaught-exception: {redacted}",
|
|||
|
|
start_epoch=start_epoch,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|