@salesforce/afv-skills 1.66.0 → 1.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +55 -5
  2. package/package.json +2 -4
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +26 -49
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/commerce-react-storefront-create/SKILL.md +350 -0
  29. package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +2 -2
  30. package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +28 -16
  31. package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +18 -1
  32. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  33. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  34. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  35. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  36. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  37. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  38. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  39. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  40. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  41. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  42. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  43. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  44. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  45. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  46. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  47. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  48. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  49. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  50. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  51. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  52. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  53. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  54. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  55. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  56. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  57. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  58. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  59. package/skills/education-cloud-domain-configure/SKILL.md +22 -24
  60. package/skills/education-cloud-domain-configure/references/dependent-features.md +9 -0
  61. package/skills/education-cloud-domain-configure/references/domain-features.md +15 -0
  62. package/skills/education-cloud-domain-configure/references/gotchas-extended.md +8 -0
  63. package/skills/education-cloud-domain-configure/references/setup-widgets.md +123 -0
  64. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  65. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  66. package/skills/experience-design-validate/SKILL.md +1 -1
  67. package/skills/experience-search-coordinate/SKILL.md +19 -13
  68. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  69. package/skills/experience-search-coordinate/references/media-route.md +30 -4
  70. package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -1
  71. package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -1
  72. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  73. package/skills/platform-apex-generate/SKILL.md +14 -11
  74. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  75. package/skills/platform-datamask-run/SKILL.md +52 -71
  76. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  77. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  78. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  79. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  80. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  81. package/skills/platform-metadata-deploy/README.md +1 -1
  82. package/skills/platform-metadata-deploy/SKILL.md +114 -15
  83. package/skills/platform-metadata-deploy/references/agent-deployment-guide.md +3 -4
  84. package/skills/platform-metadata-deploy/references/orchestration.md +2 -3
  85. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  86. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  87. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  88. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  89. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  90. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  91. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  92. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  93. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  94. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  95. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  96. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  97. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  98. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  99. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  100. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  101. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  102. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  103. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  104. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  105. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  106. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  107. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  108. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  109. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  110. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  111. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  112. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  113. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  114. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  115. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  116. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  117. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  118. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  119. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  120. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  121. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  122. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  123. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  124. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  125. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  126. package/skills/platform-report-generate/SKILL.md +9 -4
  127. package/skills/platform-report-generate/references/chart-types.md +26 -2
  128. package/skills/platform-report-generate/references/column-names.md +43 -0
  129. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  130. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  131. package/skills/platform-value-set-generate/SKILL.md +19 -19
  132. package/skills/platform-widget-generate/SKILL.md +25 -51
  133. package/skills/platform-widget-generate/examples/formulas.json +19 -2
  134. package/skills/platform-widget-generate/examples/localized-labels.json +57 -0
  135. package/skills/platform-widget-generate/references/widget-actions.md +29 -0
  136. package/skills/platform-widget-generate/references/widget-bundle-validation.md +1 -0
  137. package/skills/platform-widget-generate/references/widget-formulas.md +24 -7
  138. package/skills/platform-widget-generate/references/widget-gotchas.md +10 -0
  139. package/skills/platform-widget-generate/references/widget-labels.md +78 -0
  140. package/skills/platform-widget-generate/references/widget-styling.md +12 -0
  141. package/skills/sales-call-scoring-configure/SKILL.md +17 -10
  142. package/skills/sales-call-scoring-configure/scripts/enable-call-scoring.sh +55 -33
  143. package/skills/service-digital-engagement-channel-configure/SKILL.md +11 -7
  144. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  145. package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +123 -16
  146. package/skills/service-digital-engagement-deployment-configure/SKILL.md +37 -100
  147. package/skills/service-digital-engagement-deployment-configure/assets/esd_web_update_template.xml +83 -8
  148. package/skills/service-digital-engagement-deployment-configure/examples/esd_web_prechat_fields.xml +74 -0
  149. package/skills/service-digital-engagement-deployment-configure/references/connect_api_creation.md +13 -3
  150. package/skills/service-digital-engagement-deployment-configure/references/deployment_settings.md +13 -78
  151. package/skills/service-digital-engagement-deployment-configure/references/onboarding_contract.md +5 -6
  152. package/skills/service-digital-engagement-deployment-configure/references/prechat_forms.md +245 -0
  153. package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +164 -120
  154. package/skills/service-itsm-agentic-setup-incident-management/examples/output-templates.md +61 -37
  155. package/skills/service-itsm-agentic-setup-incident-management/references/audit-tracking.md +42 -0
  156. package/skills/service-itsm-agentic-setup-incident-management/references/broadcast-communications.md +23 -0
  157. package/skills/service-itsm-agentic-setup-incident-management/references/email-to-incident.md +23 -0
  158. package/skills/service-itsm-agentic-setup-incident-management/references/incident-dashboards.md +25 -0
  159. package/skills/service-itsm-agentic-setup-incident-management/references/incident-persona-psg.md +30 -146
  160. package/skills/service-itsm-agentic-setup-incident-management/references/incident-preferences.md +32 -136
  161. package/skills/service-itsm-agentic-setup-incident-management/references/major-incident-management.md +32 -155
  162. package/skills/service-itsm-agentic-setup-incident-management/references/manage-incidents-with-ai.md +45 -0
  163. package/skills/service-itsm-agentic-setup-incident-management/references/priority-matrix.md +32 -0
  164. package/skills/service-itsm-agentic-setup-incident-management/references/service-mgmt-privilege.md +30 -140
  165. package/skills/service-itsm-agentic-setup-incident-management/references/sla-milestones.md +110 -0
@@ -0,0 +1,1012 @@
1
+ """Fetch STDM conversations with read-only anonymous Apex (bt_fetch.py); nothing is deployed.
2
+
3
+ Python writes every query. The Apex generated around them only runs them, with
4
+ ConnectApi.CdpQuery.queryAnsiSqlV2 (nextBatchAnsiSqlV2 for later result pages), and prints each
5
+ page as one debug line, `STDM_RESULT_<query>_<page>:{"rows": [...], "more": <bool>}`, then
6
+ `STDM_RESULT_end:{}`. check_read_only() vets each script before it runs.
7
+
8
+ The rest happens here, where it is tested:
9
+ - the agent filter (participant API name, else the agent's planner ids) fails loudly when it
10
+ matches nothing, as OfflineExperimentationService.findSessions now does (it ran unfiltered);
11
+ - candidates are the sessions started in the window with at least one TURN interaction, read
12
+ in keyset pages;
13
+ - selection: channels (by default all but Builder*, Agent Builder's test traffic), excluded
14
+ sessions, the split, then a salted-hash sample (or the most recent) up to the cap;
15
+ - details come in batches of a few sessions, halved when a batch hits an Apex limit or the
16
+ 20 MB debug log;
17
+ - assembly mirrors getMultipleConversationDetails: the same ConversationData fields, NOT_SET and
18
+ blank values as null, SESSION_END interactions skipped, the same message-type inference. One
19
+ deliberate difference: duration_ms is exact, where the class's timestamp parse drops milliseconds.
20
+
21
+ The queries name the DMOs and fields of the default data space (ssot__AiAgentSession__dlm,
22
+ ssot__Id__c). A data space that names them its own way takes a names map (StdmNames,
23
+ bt.stdm_names/1, bt_fetch.py --stdm-names); without one, nothing is renamed.
24
+ """
25
+
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import re
30
+ import subprocess
31
+ from collections import Counter
32
+ from datetime import datetime, timedelta, timezone
33
+
34
+ from . import SCHEMA_FETCH, stdm
35
+ from .redact import redact
36
+ from .splits import DEFAULT_SALT as SPLIT_SALT
37
+ from .splits import session_split
38
+
39
+ SESSION = '"ssot__AiAgentSession__dlm"'
40
+ PARTICIPANT = '"ssot__AiAgentSessionParticipant__dlm"'
41
+ INTERACTION = '"ssot__AiAgentInteraction__dlm"'
42
+ MESSAGE = '"ssot__AiAgentInteractionMessage__dlm"'
43
+ STEP = '"ssot__AiAgentInteractionStep__dlm"'
44
+ PROBE_DMOS = {"session": SESSION, "participant": PARTICIPANT, "interaction": INTERACTION, "message": MESSAGE,
45
+ "step": STEP, "generation": '"GenAIGeneration__dlm"', "gateway": '"GenAIGatewayRequest__dlm"'}
46
+ REQUIRED_DMOS = ("session", "participant", "interaction", "message", "step") # the others: LLM step details only
47
+ STEP_COLUMNS = ("ssot__Id__c", "ssot__AiAgentInteractionId__c", "ssot__AiAgentInteractionStepType__c",
48
+ "ssot__Name__c", "ssot__StartTimestamp__c", "ssot__EndTimestamp__c", "ssot__InputValueText__c",
49
+ "ssot__OutputValueText__c", "ssot__ErrorMessageText__c", "ssot__PreStepVariableText__c",
50
+ "ssot__PostStepVariableText__c", "ssot__GenerationId__c", "ssot__GenAiGatewayRequestId__c")
51
+
52
+ CANDIDATE_PAGE = 2000 # rows per candidates query; the next one starts after the last session id
53
+ MAX_CANDIDATES = 20000
54
+ MAX_PAGES = 50 # result pages the Apex follows per query
55
+ BATCH_SIZE = 3 # sessions per details run (the Apex heap is 6 MB)
56
+ MAX_SESSIONS = 50
57
+ SAMPLE_SALT = "bt-sample/1"
58
+ BUILDER_PREFIX = "builder" # 'Builder', 'Builder: Voice Preview': test traffic from Agent Builder
59
+ APEX_TIMEOUT = 600
60
+ END = "STDM_RESULT_end"
61
+
62
+ _NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,79}\Z")
63
+ _ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}\Z")
64
+ _ORG = re.compile(r"[A-Za-z0-9_.@+][A-Za-z0-9_.@+-]{0,119}\Z")
65
+ _TIME = re.compile(r"(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?"
66
+ r"\s*(Z|UTC|GMT|[+-]\d{2}:?\d{2})?\Z", re.I)
67
+ # Errors a smaller batch can avoid: Apex limits, and a debug log cut at 20 MB.
68
+ _TOO_BIG = re.compile(r"heap size|cpu time|too many|exceed|probably truncated|pages missing|end marker", re.I)
69
+
70
+
71
+ class FetchError(Exception):
72
+ pass
73
+
74
+
75
+ class BatchTooBig(FetchError):
76
+ """The run hit an Apex limit or the debug-log cap: retry with fewer sessions."""
77
+
78
+
79
+ class ReadOnlyError(FetchError):
80
+ pass
81
+
82
+
83
+ # --- literals ---------------------------------------------------------------------------------
84
+
85
+ def check_name(value, what="agent API name"):
86
+ if not isinstance(value, str) or not _NAME.match(value):
87
+ raise FetchError(f"not an {what} (a letter, then letters, digits or _): {value!r}")
88
+ return value
89
+
90
+
91
+ def check_ids(ids, what="session id"):
92
+ ids = list(ids)
93
+ bad = sum(1 for i in ids if not isinstance(i, str) or not _ID.match(i))
94
+ if bad: # not echoed: ids name sessions
95
+ raise FetchError(f"{bad} {what}s hold characters other than letters, digits, _ . : -")
96
+ return ids
97
+
98
+
99
+ def check_org(value):
100
+ if not isinstance(value, str) or not _ORG.match(value):
101
+ raise FetchError(f"not an org alias or username: {value!r}")
102
+ return value
103
+
104
+
105
+ def _lit(value):
106
+ """A SQL string literal of an already-validated value."""
107
+ if "'" in value or "\\" in value:
108
+ raise FetchError("a quote or backslash in a query value")
109
+ return "'" + value + "'"
110
+
111
+
112
+ def _in(ids, what="session id"):
113
+ ids = check_ids(ids, what)
114
+ if not ids:
115
+ raise FetchError(f"no {what}s for an IN list")
116
+ return "(" + ", ".join(_lit(i) for i in ids) + ")"
117
+
118
+
119
+ # --- timestamps -------------------------------------------------------------------------------
120
+
121
+ def parse_time(value):
122
+ """An aware UTC datetime from an STDM timestamp ('2026-10-01 17:23:45.123 UTC'), ISO 8601
123
+ with T, Z or an offset, or epoch milliseconds; None for None, blank or unparseable."""
124
+ if value is None or isinstance(value, bool):
125
+ return None
126
+ if isinstance(value, (int, float)):
127
+ return datetime.fromtimestamp(value / 1000, timezone.utc)
128
+ text = str(value).strip()
129
+ if text.isdigit():
130
+ return datetime.fromtimestamp(int(text) / 1000, timezone.utc)
131
+ m = _TIME.match(text)
132
+ if not m:
133
+ return None
134
+ y, mo, d, hh, mi, ss, frac, tz = m.groups()
135
+ try:
136
+ dt = datetime(int(y), int(mo), int(d), int(hh or 0), int(mi or 0), int(ss or 0),
137
+ int((frac or "0")[:6].ljust(6, "0")))
138
+ except ValueError:
139
+ return None
140
+ if tz and tz.upper() not in ("Z", "UTC", "GMT"):
141
+ digits = tz[1:].replace(":", "")
142
+ offset = timedelta(hours=int(digits[:2]), minutes=int(digits[2:]))
143
+ dt = dt - offset if tz[0] == "+" else dt + offset
144
+ return dt.replace(tzinfo=timezone.utc)
145
+
146
+
147
+ def window_bound(text, end=False):
148
+ """--start / --end as the literal the queries compare with, 'YYYY-MM-DDTHH:MM:SS.fffZ' (UTC).
149
+ A date alone starts at 00:00:00.000, or as --end runs to 23:59:59.999."""
150
+ text = (text or "").strip()
151
+ dt = parse_time(text) if not text.isdigit() else None
152
+ if dt is None:
153
+ raise FetchError(f"not a date or ISO 8601 time: {text!r}")
154
+ if end and re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
155
+ dt += timedelta(days=1, milliseconds=-1)
156
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (dt.microsecond // 1000)
157
+
158
+
159
+ def duration_ms(start, end):
160
+ a, b = parse_time(start), parse_time(end)
161
+ if a is None or b is None:
162
+ return None
163
+ return (b - a) // timedelta(milliseconds=1)
164
+
165
+
166
+ # --- queries ----------------------------------------------------------------------------------
167
+
168
+ _SESSION_COLS = ("s.ssot__Id__c, s.ssot__StartTimestamp__c, s.ssot__EndTimestamp__c, "
169
+ "s.ssot__AiAgentChannelType__c, s.ssot__AiAgentSessionEndType__c")
170
+
171
+
172
+ def candidates_sql(start, end, agent=None, participant_ids=None, after=None, limit=None):
173
+ """Sessions of the agent started in [start, end] with at least one TURN, by session id, from
174
+ after (exclusive), limit (default CANDIDATE_PAGE) rows; the last column counts their TURN interactions."""
175
+ if participant_ids is not None:
176
+ who = "p.ssot__ParticipantId__c IN " + _in(participant_ids, "planner id")
177
+ else:
178
+ who = "p.ssot__AiAgentApiName__c = " + _lit(check_name(agent))
179
+ return ("SELECT " + _SESSION_COLS + ", COUNT(DISTINCT i.ssot__Id__c) FROM " + SESSION + " s"
180
+ " JOIN " + PARTICIPANT + " p ON p.ssot__AiAgentSessionId__c = s.ssot__Id__c"
181
+ " JOIN " + INTERACTION + " i ON i.ssot__AiAgentSessionId__c = s.ssot__Id__c"
182
+ " WHERE " + who + " AND i.ssot__AiAgentInteractionType__c = 'TURN'"
183
+ " AND s.ssot__StartTimestamp__c >= " + _lit(start) + " AND s.ssot__StartTimestamp__c <= " + _lit(end)
184
+ + ("" if after is None else " AND s.ssot__Id__c > " + _lit(check_ids([after])[0]))
185
+ + " GROUP BY " + _SESSION_COLS + " ORDER BY s.ssot__Id__c LIMIT "
186
+ + str(int(CANDIDATE_PAGE if limit is None else limit)))
187
+
188
+
189
+ def details_sql(session_ids):
190
+ """The four queries of getConversationDetails for a batch of sessions; messages and steps are
191
+ joined to their interaction, so one run needs no interaction ids. Ties are ordered by id."""
192
+ ids = _in(session_ids)
193
+ return {
194
+ "sessions": ("SELECT ssot__Id__c, ssot__StartTimestamp__c, ssot__EndTimestamp__c, "
195
+ "ssot__AiAgentChannelType__c, ssot__AiAgentSessionEndType__c, ssot__VariableText__c FROM "
196
+ + SESSION + " WHERE ssot__Id__c IN " + ids),
197
+ "interactions": ("SELECT ssot__Id__c, ssot__AiAgentSessionId__c, ssot__TopicApiName__c, "
198
+ "ssot__AiAgentInteractionType__c, ssot__StartTimestamp__c, ssot__EndTimestamp__c, "
199
+ "ssot__TelemetryTraceId__c FROM " + INTERACTION + " WHERE ssot__AiAgentSessionId__c IN " + ids
200
+ + " ORDER BY ssot__StartTimestamp__c, ssot__Id__c"),
201
+ "messages": ("SELECT m.ssot__Id__c, m.ssot__AiAgentInteractionId__c, m.ssot__AiAgentInteractionMessageType__c, "
202
+ "m.ssot__ContentText__c, m.ssot__MessageSentTimestamp__c FROM " + MESSAGE + " m JOIN "
203
+ + INTERACTION + " i ON i.ssot__Id__c = m.ssot__AiAgentInteractionId__c"
204
+ " WHERE i.ssot__AiAgentSessionId__c IN " + ids
205
+ + " ORDER BY m.ssot__MessageSentTimestamp__c, m.ssot__Id__c"),
206
+ "steps": ("SELECT " + ", ".join("st." + c for c in STEP_COLUMNS) + " FROM " + STEP + " st JOIN "
207
+ + INTERACTION + " i ON i.ssot__Id__c = st.ssot__AiAgentInteractionId__c"
208
+ " WHERE i.ssot__AiAgentSessionId__c IN " + ids
209
+ + " ORDER BY st.ssot__StartTimestamp__c, st.ssot__Id__c"),
210
+ }
211
+
212
+
213
+ def count_sql(table, where=None):
214
+ return "SELECT COUNT(*) FROM " + table + ("" if where is None else " WHERE " + where)
215
+
216
+
217
+ def agents_in_window_sql(start, end):
218
+ return ("SELECT p.ssot__AiAgentApiName__c, COUNT(DISTINCT s.ssot__Id__c) FROM " + SESSION + " s JOIN "
219
+ + PARTICIPANT + " p ON p.ssot__AiAgentSessionId__c = s.ssot__Id__c"
220
+ " WHERE s.ssot__StartTimestamp__c >= " + _lit(start) + " AND s.ssot__StartTimestamp__c <= " + _lit(end)
221
+ + " GROUP BY p.ssot__AiAgentApiName__c")
222
+
223
+
224
+ # --- a data space's own names -----------------------------------------------------------------
225
+
226
+ SCHEMA_NAMES = "bt.stdm_names/1"
227
+ NO_MATCH = "bt_probe_none" # a key no row has: the query-shape checks read nothing
228
+ SHAPES = {"qcands": "candidates by API name", "qbyid": "candidates by planner id", "qagents": "agents in a window",
229
+ "qsessions": "session details", "qinteractions": "interactions", "qmessages": "messages", "qsteps": "steps"}
230
+ _NAMES_KEYS = frozenset(("schema", "about", "dmo_prefix", "field_prefix", "dmos", "fields", "user_agent"))
231
+ _PREFIX = re.compile(r"(?:[A-Za-z][A-Za-z0-9_]{0,39})?\Z")
232
+ _USER_AGENT = re.compile(r"[A-Za-z][A-Za-z0-9_.-]{0,39}\Z")
233
+ _DMO_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,74}__dlm\Z")
234
+ _FIELD_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,76}__c\Z")
235
+ _IDENT = re.compile(r'"([A-Za-z][A-Za-z0-9_]*)"|\b([A-Za-z][A-Za-z0-9_]*__c)\b') # a quoted DMO, or a field
236
+
237
+
238
+ def _outside_literals(sql, fn):
239
+ """sql with fn applied to the text between its string literals (_lit's hold no quote)."""
240
+ return "".join(p if i % 2 else fn(p) for i, p in enumerate(re.split(r"('[^']*')", sql)))
241
+
242
+
243
+ def shape_queries():
244
+ """Every query shape a fetch runs, with keys no row has: each reads 0 rows, or fails on a name
245
+ the data space does not have."""
246
+ start, end = window_bound("1970-01-01"), window_bound("1970-01-01", end=True)
247
+ queries = {"qcands": candidates_sql(start, end, agent=NO_MATCH),
248
+ "qbyid": candidates_sql(start, end, participant_ids=[NO_MATCH]),
249
+ "qagents": agents_in_window_sql(start, end)}
250
+ queries.update(("q" + name, sql) for name, sql in details_sql([NO_MATCH]).items())
251
+ return queries
252
+
253
+
254
+ def default_names():
255
+ """(the DMOs, the fields) the queries name, as the default data space names them."""
256
+ fields = set()
257
+ for sql in shape_queries().values():
258
+ for text in re.split(r"'[^']*'", sql): # the text between the literals
259
+ fields.update(m.group(2) for m in _IDENT.finditer(text) if m.group(2))
260
+ return [table.strip('"') for table in PROBE_DMOS.values()], sorted(fields)
261
+
262
+
263
+ class StdmNames:
264
+ """A data space's own names for the STDM DMOs and their fields, a bt.stdm_names/1 map:
265
+ dmo_prefix and field_prefix replace the ssot__ of the default names (default: ssot__ itself),
266
+ dmos and fields rename single ones, keyed by the default name, and user_agent, when given, is
267
+ the CdpQueryInput.userAgent of every query. GenAIGeneration__dlm and GenAIGatewayRequest__dlm
268
+ have no ssot__: only dmos renames them."""
269
+
270
+ def __init__(self, data, source="", sha256=""):
271
+ if not isinstance(data, dict):
272
+ raise FetchError("a names map is a JSON object")
273
+ unknown = sorted(set(data) - _NAMES_KEYS)
274
+ if unknown:
275
+ raise FetchError("not a key of a names map: " + ", ".join(map(repr, unknown[:5])))
276
+ if data.get("schema") != SCHEMA_NAMES:
277
+ raise FetchError(f"not a {SCHEMA_NAMES} names map (schema {data.get('schema')!r})")
278
+ self.source, self.sha256 = source, sha256
279
+ self.about = data.get("about") or ""
280
+ if not isinstance(self.about, str) or len(self.about) > 300 or not self.about.isprintable():
281
+ raise FetchError("about: one line of text, at most 300 characters")
282
+ prefix = {}
283
+ for key in ("dmo_prefix", "field_prefix"):
284
+ prefix[key] = data.get(key, "ssot__")
285
+ if not isinstance(prefix[key], str) or not _PREFIX.match(prefix[key]):
286
+ raise FetchError(f"{key}: empty, or a letter, then letters, digits or _: {prefix[key]!r}")
287
+ self.user_agent = data.get("user_agent")
288
+ if self.user_agent is not None and not (isinstance(self.user_agent, str) and _USER_AGENT.match(self.user_agent)):
289
+ raise FetchError(f"user_agent: a letter, then letters, digits or _ . -: {self.user_agent!r}")
290
+ dmos, fields = default_names()
291
+ self._dmos = self._renamed(data.get("dmos"), "dmos", dmos, prefix["dmo_prefix"], _DMO_NAME)
292
+ self._fields = self._renamed(data.get("fields"), "fields", fields, prefix["field_prefix"], _FIELD_NAME)
293
+
294
+ @staticmethod
295
+ def _renamed(given, key, defaults, prefix, shape):
296
+ """{default name: this data space's} for each default name; refuses a name that cannot be
297
+ one, a key that is no default name, and two default names given one name."""
298
+ given = {} if given is None else given
299
+ if not isinstance(given, dict):
300
+ raise FetchError(f"{key}: an object of default name -> name")
301
+ unknown = sorted(set(given) - set(defaults))
302
+ if unknown:
303
+ raise FetchError(f"{key}: not a name the queries use: " + ", ".join(map(repr, unknown[:5])))
304
+ out = {}
305
+ for name in defaults:
306
+ new = given.get(name)
307
+ if new is None:
308
+ new = prefix + name[len("ssot__"):] if name.startswith("ssot__") else name
309
+ if not isinstance(new, str) or not shape.match(new):
310
+ raise FetchError(f"{key}: {name} -> {new!r}, not a {'DMO' if key == 'dmos' else 'field'} name")
311
+ out[name] = new
312
+ twice = sorted(n for n, c in Counter(out.values()).items() if c > 1)
313
+ if twice:
314
+ raise FetchError(f"{key}: two names map to {twice[0]}")
315
+ return out
316
+
317
+ def dmo(self, name):
318
+ return self._dmos.get(name, name)
319
+
320
+ def field(self, name):
321
+ return self._fields.get(name, name)
322
+
323
+ def sql(self, sql):
324
+ """sql in this data space's names: its quoted DMOs and its fields, outside the literals."""
325
+ def rename(m):
326
+ return '"' + self.dmo(m.group(1)) + '"' if m.group(1) else self.field(m.group(2))
327
+ return _outside_literals(sql, lambda text: _IDENT.sub(rename, text))
328
+
329
+ def manifest(self):
330
+ return {"file": self.source, "sha256": self.sha256, "about": self.about}
331
+
332
+
333
+ def load_names(path):
334
+ """The StdmNames of a bt.stdm_names/1 file (--stdm-names)."""
335
+ try:
336
+ with open(path, "rb") as fh:
337
+ raw = fh.read()
338
+ data = json.loads(raw.decode("utf-8"))
339
+ except (OSError, ValueError) as e:
340
+ raise FetchError(f"--stdm-names {path}: {e}") from None
341
+ try:
342
+ return StdmNames(data, os.path.basename(path), hashlib.sha256(raw).hexdigest()[:16])
343
+ except FetchError as e:
344
+ raise FetchError(f"--stdm-names {path}: {e}") from None
345
+
346
+
347
+ # --- Apex -------------------------------------------------------------------------------------
348
+
349
+ def apex_literal(text):
350
+ return "'" + (text.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
351
+ .replace("\r", "\\r").replace("\t", "\\t")) + "'"
352
+
353
+
354
+ _HEAD = ("// Generated by bt_fetch.py. Read-only: runs the SELECT queries below on Data Cloud and\n"
355
+ "// prints the result rows as debug lines. No DML, no callouts, nothing deployed.\n")
356
+
357
+ _PAGES = """\
358
+ for (String name : queries.keySet()) {
359
+ ConnectApi.CdpQueryInput input = new ConnectApi.CdpQueryInput();
360
+ input.sql = queries.get(name);
361
+ ConnectApi.CdpQueryOutputV2 out = ConnectApi.CdpQuery.queryAnsiSqlV2(input, dataspace);
362
+ Integer page = 0;
363
+ while (true) {
364
+ List<List<Object>> rows = new List<List<Object>>();
365
+ if (out.data != null) {
366
+ for (ConnectApi.CdpQueryV2Row row : out.data) {
367
+ rows.add(row.rowData);
368
+ }
369
+ }
370
+ Boolean more = String.isNotBlank(out.nextBatchId);
371
+ System.debug('STDM_RESULT_' + name + '_' + page + ':' + JSON.serialize(new Map<String, Object>{'rows' => rows, 'more' => more}));
372
+ if (!more || page + 1 >= maxPages) {
373
+ break;
374
+ }
375
+ out = ConnectApi.CdpQuery.nextBatchAnsiSqlV2(out.nextBatchId, dataspace);
376
+ page++;
377
+ }
378
+ }
379
+ System.debug('STDM_RESULT_end:{}');
380
+ """
381
+
382
+ _PROBE = """\
383
+ for (String name : queries.keySet()) {
384
+ ConnectApi.CdpQueryInput input = new ConnectApi.CdpQueryInput();
385
+ input.sql = queries.get(name);
386
+ try {
387
+ ConnectApi.CdpQueryOutputV2 out = ConnectApi.CdpQuery.queryAnsiSqlV2(input, dataspace);
388
+ List<List<Object>> rows = new List<List<Object>>();
389
+ if (out.data != null) {
390
+ for (ConnectApi.CdpQueryV2Row row : out.data) {
391
+ rows.add(row.rowData);
392
+ }
393
+ }
394
+ System.debug('STDM_RESULT_' + name + '_0:' + JSON.serialize(new Map<String, Object>{'rows' => rows, 'more' => false}));
395
+ } catch (Exception e) {
396
+ System.debug('STDM_RESULT_' + name + '_0:' + JSON.serialize(new Map<String, Object>{'error' => e.getMessage()}));
397
+ }
398
+ }
399
+ System.debug('STDM_RESULT_end:{}');
400
+ """
401
+
402
+ _PLANNERS = """\
403
+ List<GenAiPlannerDefinition> planners = [SELECT Id, DeveloperName, MasterLabel FROM GenAiPlannerDefinition WHERE DeveloperName LIKE :pattern OR MasterLabel = :agent LIMIT 200];
404
+ System.debug('STDM_RESULT_planners:' + JSON.serialize(planners));
405
+ System.debug('STDM_RESULT_end:{}');
406
+ """
407
+
408
+
409
+ _SET_SQL = " input.sql = queries.get(name);\n"
410
+
411
+
412
+ def query_script(queries, dataspace, probe=False, names=None):
413
+ """Anonymous Apex running {name: sql}: every result page (or, probe: the first page, or the error).
414
+ With names (StdmNames), the SQL is in the data space's names, and the map's userAgent is set."""
415
+ lines = [_HEAD, "String dataspace = " + apex_literal(check_name(dataspace, "data space name")) + ";",
416
+ "Integer maxPages = " + str(MAX_PAGES) + ";"]
417
+ body = _PROBE if probe else _PAGES
418
+ if names is not None and names.user_agent:
419
+ lines.append("String userAgent = " + apex_literal(names.user_agent) + ";")
420
+ body = body.replace(_SET_SQL, _SET_SQL + " input.userAgent = userAgent;\n")
421
+ lines.append("Map<String, String> queries = new Map<String, String>();")
422
+ for name, sql in queries.items():
423
+ if not re.fullmatch(r"[a-z]+", name):
424
+ raise FetchError(f"query name {name!r}: lowercase letters only")
425
+ if names is not None:
426
+ sql = names.sql(sql)
427
+ if not re.match(r"SELECT ", sql) or ";" in sql:
428
+ raise FetchError(f"query {name}: one SELECT statement only")
429
+ lines.append("queries.put(" + apex_literal(name) + ", " + apex_literal(sql) + ");")
430
+ return "\n".join(lines) + "\n" + body
431
+
432
+
433
+ def planner_script(agent):
434
+ """Anonymous Apex listing the GenAiPlannerDefinitions named like the agent (SOQL, read-only)."""
435
+ check_name(agent)
436
+ return (_HEAD + "String agent = " + apex_literal(agent) + ";\nString pattern = agent + " + apex_literal("%")
437
+ + ";\n" + _PLANNERS)
438
+
439
+
440
+ # Every word the generated scripts use outside string literals. A script with any other word
441
+ # (insert, update, delete, Database, Http, a custom class...) is refused before it runs.
442
+ READ_ONLY_WORDS = frozenset("""
443
+ string integer boolean object list map new for while if break true false null try catch exception e
444
+ connectapi cdpqueryinput cdpquery queryansisqlv2 nextbatchansisqlv2 cdpqueryoutputv2 cdpqueryv2row
445
+ sql data rowdata nextbatchid json serialize system debug isnotblank getmessage put get keyset add
446
+ dataspace maxpages queries name input out page rows row more agent pattern planners useragent
447
+ select id developername masterlabel from genaiplannerdefinition where like or limit
448
+ """.split())
449
+ _WRITE_SQL = re.compile(r"\b(insert|update|upsert|delete|undelete|merge|drop|create|alter|truncate|grant)\b", re.I)
450
+
451
+
452
+ def _lex(apex):
453
+ """(the code with string literals emptied and comments removed, [string literal contents])."""
454
+ code, literals, i, n = [], [], 0, len(apex)
455
+ while i < n:
456
+ c = apex[i]
457
+ if c == "'":
458
+ j, buf = i + 1, []
459
+ while j < n and apex[j] != "'":
460
+ if apex[j] == "\n":
461
+ raise ReadOnlyError("a string literal runs past the end of its line")
462
+ if apex[j] == "\\" and j + 1 < n:
463
+ buf.append({"n": "\n", "r": "\r", "t": "\t"}.get(apex[j + 1], apex[j + 1]))
464
+ j += 2
465
+ else:
466
+ buf.append(apex[j])
467
+ j += 1
468
+ if j >= n:
469
+ raise ReadOnlyError("an unterminated string literal")
470
+ literals.append("".join(buf))
471
+ code.append("''")
472
+ i = j + 1
473
+ elif apex.startswith("//", i):
474
+ j = apex.find("\n", i)
475
+ i = n if j < 0 else j
476
+ elif apex.startswith("/*", i):
477
+ j = apex.find("*/", i + 2)
478
+ if j < 0:
479
+ raise ReadOnlyError("an unterminated comment")
480
+ code.append(" ")
481
+ i = j + 2
482
+ else:
483
+ code.append(c)
484
+ i += 1
485
+ return "".join(code), literals
486
+
487
+
488
+ def check_read_only(apex):
489
+ """Refuse a script that could do anything but read: every word outside string literals must
490
+ be on READ_ONLY_WORDS, every SQL literal must be one SELECT, and no literal may hold a
491
+ write statement keyword."""
492
+ code, literals = _lex(apex)
493
+ words = {w.lower() for w in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", code)}
494
+ extra = sorted(words - READ_ONLY_WORDS)
495
+ if extra:
496
+ raise ReadOnlyError("not on the read-only word list: " + ", ".join(extra[:10]))
497
+ for lit in literals:
498
+ bad = _WRITE_SQL.search(lit)
499
+ if bad:
500
+ raise ReadOnlyError(f"a string literal holds {bad.group(1)!r}")
501
+ if re.match(r"\s*(select|with|call|exec)\b", lit, re.I) and (not lit.startswith("SELECT ") or ";" in lit):
502
+ raise ReadOnlyError("a query literal that is not one SELECT statement")
503
+
504
+
505
+ # --- running ----------------------------------------------------------------------------------
506
+
507
+ def sf_runner(argv, timeout):
508
+ """Run the sf CLI (no shell): its --json output, on stdout (stderr if stdout is empty)."""
509
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
510
+ return proc.stdout if proc.stdout.strip() else proc.stderr
511
+
512
+
513
+ _UUID = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
514
+ _SFID = re.compile(r"\b(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?\b")
515
+ _TENANT = re.compile(r"\b(a360|core)/[\w./-]+")
516
+
517
+
518
+ def mask_ids(text):
519
+ """Error text with session/record ids and tenant ids masked, for printing."""
520
+ text = _UUID.sub("<id>", redact(text))
521
+ text = _TENANT.sub(lambda m: m.group(1) + "/<tenant>", text)
522
+ return _SFID.sub("<id>", text)
523
+
524
+
525
+ def collect_pages(found, names):
526
+ """{name: rows} from the {marker: payload} of a run of query_script: each query's pages, in
527
+ order, all present, the last without more."""
528
+ if END not in found:
529
+ raise BatchTooBig("the Apex log has no end marker (the run stopped early, or the log was cut)")
530
+ pages = {}
531
+ for marker, payload in found.items():
532
+ if marker == END or not marker or not marker.startswith("STDM_RESULT_"):
533
+ continue
534
+ name, _, page = marker[len("STDM_RESULT_"):].rpartition("_")
535
+ if not page.isdigit():
536
+ continue
537
+ if not isinstance(payload, dict):
538
+ raise FetchError(f"query {name}: a result page that is not an object")
539
+ if "error" in payload:
540
+ raise FetchError(f"query {name}: {mask_ids(str(payload['error']))[:300]}")
541
+ if not isinstance(payload.get("rows"), list):
542
+ raise FetchError(f"query {name}: a result page without rows")
543
+ pages.setdefault(name, {})[int(page)] = payload
544
+ out = {}
545
+ for name in names:
546
+ got = pages.get(name, {})
547
+ last = got.get(len(got) - 1, {})
548
+ # a page the log lost, the last one too: the Apex stops before the end only at maxPages
549
+ if not got or sorted(got) != list(range(len(got))) or (last.get("more") and len(got) < MAX_PAGES):
550
+ raise BatchTooBig(f"query {name}: result pages missing from the Apex log (cut at 20 MB?)")
551
+ if last.get("more"):
552
+ raise FetchError(f"query {name}: more than {MAX_PAGES} result pages; narrow the window or "
553
+ "lower --batch-size")
554
+ out[name] = [row for i in range(len(got)) for row in got[i]["rows"]]
555
+ return out
556
+
557
+
558
+ class Fetcher:
559
+ """Runs generated, read-only Apex in an org: `sf apex run --json --file <script>`. Each script
560
+ is kept in apex_dir (created 0700), numbered in run order; those of details hold session ids.
561
+ names: the data space's StdmNames, or None for the default names."""
562
+
563
+ def __init__(self, org, apex_dir, dataspace="default", runner=None, timeout=APEX_TIMEOUT, names=None):
564
+ self.org = check_org(org)
565
+ self.dataspace = check_name(dataspace, "data space name")
566
+ self.apex_dir = apex_dir
567
+ self.runner = runner or sf_runner
568
+ self.timeout = timeout
569
+ self.names = names
570
+ self.runs = 0
571
+
572
+ def run(self, apex, label):
573
+ """{marker: payload} of one Apex run; BatchTooBig when a smaller batch could pass."""
574
+ check_read_only(apex)
575
+ self.runs += 1
576
+ path = os.path.join(_private_dir(self.apex_dir), "%03d_%s.apex" % (self.runs, label))
577
+ write_private(path, apex)
578
+ argv = ["sf", "apex", "run", "--json", "--file", path, "--target-org", self.org]
579
+ try:
580
+ out = self.runner(argv, self.timeout)
581
+ except subprocess.TimeoutExpired:
582
+ raise BatchTooBig(f"{label}: sf apex run exceeded {self.timeout}s") from None
583
+ except OSError as e:
584
+ raise FetchError(f"could not run the sf CLI: {e}") from None
585
+ try:
586
+ data = json.loads(out or "")
587
+ except ValueError:
588
+ raise FetchError(f"{label}: sf apex run printed no JSON: {mask_ids((out or '').strip()[-300:])}") from None
589
+ try:
590
+ found = stdm.parse_sf_output(data, label)
591
+ except stdm.StdmError as e:
592
+ msg = mask_ids(str(e))
593
+ raise (BatchTooBig if _TOO_BIG.search(msg) else FetchError)(msg) from None
594
+ if found is None:
595
+ raise FetchError(f"{label}: not the output of sf apex run --json")
596
+ return found
597
+
598
+ def query(self, queries, label):
599
+ return collect_pages(self.run(query_script(queries, self.dataspace, names=self.names), label), queries)
600
+
601
+ def candidates(self, start, end, agent=None, participant_ids=None):
602
+ rows, after = [], None
603
+ while True:
604
+ page = self.query({"cands": candidates_sql(start, end, agent, participant_ids, after)}, "candidates")["cands"]
605
+ rows.extend(page)
606
+ if len(rows) > MAX_CANDIDATES:
607
+ raise FetchError(f"more than {MAX_CANDIDATES} candidate sessions in the window; narrow it")
608
+ if len(page) < CANDIDATE_PAGE:
609
+ break
610
+ after = col(page[-1], 0)
611
+ seen, out = set(), []
612
+ for r in rows:
613
+ c = candidate(r)
614
+ if c["session_id"] and c["session_id"] not in seen: # a session's rows can repeat in the DMO
615
+ seen.add(c["session_id"])
616
+ out.append(c)
617
+ return out
618
+
619
+ def planners(self, agent):
620
+ """(planners that are the agent: DeveloperName == agent or agent_<n> / agent_v<n>, or
621
+ MasterLabel == agent; every DeveloperName the LIKE matched)."""
622
+ rows = self.run(planner_script(agent), "planners").get("STDM_RESULT_planners")
623
+ if not isinstance(rows, list):
624
+ raise FetchError("planners: no result line")
625
+ rows = [r for r in rows if isinstance(r, dict) and r.get("Id")]
626
+ version = re.compile(re.escape(agent) + r"_v?\d+\Z")
627
+ mine = [r for r in rows if r.get("DeveloperName") == agent or r.get("MasterLabel") == agent
628
+ or version.match(r.get("DeveloperName") or "")]
629
+ return mine, sorted({r.get("DeveloperName") or "?" for r in rows})
630
+
631
+ def details(self, session_ids):
632
+ return assemble(session_ids, self.query(details_sql(session_ids), "details"))
633
+
634
+
635
+ # --- rows -> records --------------------------------------------------------------------------
636
+
637
+ def col(row, i):
638
+ """A column as the class's col(): null stays null, text as is, other values as text."""
639
+ if row is None or i >= len(row) or row[i] is None:
640
+ return None
641
+ v = row[i]
642
+ if isinstance(v, str):
643
+ return v
644
+ if isinstance(v, bool):
645
+ return "true" if v else "false"
646
+ if isinstance(v, (dict, list)):
647
+ return json.dumps(v)
648
+ return str(v)
649
+
650
+
651
+ def not_set(value):
652
+ """The class's notSet(): blank or 'NOT_SET' is null (Apex compares strings ignoring case)."""
653
+ return None if value is None or not value.strip() or value.upper() == "NOT_SET" else value
654
+
655
+
656
+ def candidate(row):
657
+ turns = col(row, 5)
658
+ return {"session_id": col(row, 0), "start_time": col(row, 1), "end_time": col(row, 2), "channel": col(row, 3),
659
+ "end_type": not_set(col(row, 4)), "turns": int(float(turns)) if turns else 0}
660
+
661
+
662
+ def session_summary(c):
663
+ """A SessionSummary of findSessions, with an exact duration."""
664
+ return {"session_id": c["session_id"], "start_time": c["start_time"], "end_time": c["end_time"],
665
+ "channel": c["channel"], "duration_ms": duration_ms(c["start_time"], c["end_time"]),
666
+ "end_type": c["end_type"]}
667
+
668
+
669
+ def infer_message_types(messages):
670
+ """getConversationDetails' fill-in of null message types: all null alternate Input/Output from
671
+ the first; otherwise each null is the opposite of the one before, and a first null is Input."""
672
+ types = [m["message_type"] for m in messages]
673
+ if None not in types:
674
+ return
675
+ if all(t is None for t in types):
676
+ for i, m in enumerate(messages):
677
+ m["message_type"] = "Input" if i % 2 == 0 else "Output"
678
+ return
679
+ for i, m in enumerate(messages):
680
+ if m["message_type"] is None:
681
+ if i > 0 and messages[i - 1]["message_type"] is not None:
682
+ m["message_type"] = "Output" if messages[i - 1]["message_type"] == "Input" else "Input"
683
+ elif i == 0:
684
+ m["message_type"] = "Input"
685
+
686
+
687
+ def assemble(session_ids, rows):
688
+ """ConversationData for each session id, in order, from the rows of details_sql()."""
689
+ header = {}
690
+ for r in rows["sessions"]:
691
+ header.setdefault(col(r, 0), r) # the class reads the first row
692
+ convos, by_session, turn_of, convo_of = [], {}, {}, {}
693
+ for sid in session_ids:
694
+ c = {"session_id": sid, "start_time": None, "end_time": None, "channel": None, "duration_ms": None,
695
+ "end_type": None, "session_variables": None, "turn_count": 0, "action_error_count": 0, "turns": []}
696
+ r = header.get(sid)
697
+ if r is not None:
698
+ c.update(start_time=col(r, 1), end_time=col(r, 2), channel=col(r, 3), end_type=not_set(col(r, 4)),
699
+ session_variables=not_set(col(r, 5)))
700
+ c["duration_ms"] = duration_ms(c["start_time"], c["end_time"])
701
+ convos.append(c)
702
+ by_session[sid] = c
703
+ for r in rows["interactions"]:
704
+ c = by_session.get(col(r, 1))
705
+ if c is None or (col(r, 3) or "").upper() == "SESSION_END":
706
+ continue
707
+ t = {"interaction_id": col(r, 0), "topic": col(r, 2), "start_time": col(r, 4), "end_time": col(r, 5),
708
+ "duration_ms": duration_ms(col(r, 4), col(r, 5)), "telemetry_trace_id": not_set(col(r, 6)),
709
+ "messages": [], "steps": []}
710
+ c["turns"].append(t)
711
+ turn_of[t["interaction_id"]] = t
712
+ convo_of[t["interaction_id"]] = c
713
+ for c in convos:
714
+ c["turn_count"] = len(c["turns"])
715
+ for r in rows["messages"]:
716
+ t = turn_of.get(col(r, 1))
717
+ if t is not None:
718
+ t["messages"].append({"message_id": col(r, 0), "message_type": col(r, 2), "text": col(r, 3),
719
+ "sent_at": col(r, 4)})
720
+ for t in turn_of.values():
721
+ infer_message_types(t["messages"])
722
+ for r in rows["steps"]:
723
+ t = turn_of.get(col(r, 1))
724
+ if t is None:
725
+ continue
726
+ s = {"step_id": col(r, 0), "step_type": col(r, 2), "name": col(r, 3), "start_time": col(r, 4),
727
+ "end_time": col(r, 5), "duration_ms": duration_ms(col(r, 4), col(r, 5)), "input": not_set(col(r, 6)),
728
+ "output": not_set(col(r, 7)), "error": not_set(col(r, 8)), "pre_vars": not_set(col(r, 9)),
729
+ "post_vars": not_set(col(r, 10)), "generation_id": not_set(col(r, 11)),
730
+ "gateway_request_id": not_set(col(r, 12))}
731
+ if s["error"] is not None and (s["step_type"] or "").upper() == "ACTION_STEP":
732
+ convo_of[col(r, 1)]["action_error_count"] += 1
733
+ t["steps"].append(s)
734
+ return convos
735
+
736
+
737
+ # --- selection --------------------------------------------------------------------------------
738
+
739
+ def channel_kept(channel, channels=None):
740
+ """With channels: one of them (case-insensitive). Without: any but the Builder* channels."""
741
+ name = (channel or "").strip().lower()
742
+ if channels:
743
+ return name in {c.strip().lower() for c in channels}
744
+ return not name.startswith(BUILDER_PREFIX)
745
+
746
+
747
+ def sample_key(session_id, salt=SAMPLE_SALT):
748
+ return hashlib.sha256(f"{salt}\0{session_id}".encode("utf-8")).hexdigest()
749
+
750
+
751
+ def select(cands, channels=None, exclude=frozenset(), split=None, val_fraction=None, split_salt=SPLIT_SALT,
752
+ order="hash", sample_salt=SAMPLE_SALT, max_sessions=MAX_SESSIONS):
753
+ """(the sessions to fetch, in rank order; counts). Channel, excluded sessions and split filter
754
+ in that order; the rest is ranked (a salted hash, or newest first) and cut at max_sessions."""
755
+ counts = {"candidates": len(cands),
756
+ "by_channel": dict(sorted(Counter(c["channel"] or "(none)" for c in cands).items()))}
757
+ kept = [c for c in cands if channel_kept(c["channel"], channels)]
758
+ counts["dropped_channel"] = len(cands) - len(kept)
759
+ n = len(kept)
760
+ kept = [c for c in kept if c["session_id"] not in exclude]
761
+ counts["excluded"] = n - len(kept)
762
+ n = len(kept)
763
+ if split is not None:
764
+ kept = [c for c in kept if session_split(c["session_id"], val_fraction, split_salt) == split]
765
+ counts["other_split"] = n - len(kept)
766
+ counts["available"] = len(kept)
767
+ if order == "recent":
768
+ oldest = datetime.min.replace(tzinfo=timezone.utc)
769
+ kept.sort(key=lambda c: c["session_id"])
770
+ kept.sort(key=lambda c: parse_time(c["start_time"]) or oldest, reverse=True)
771
+ elif order == "hash":
772
+ kept.sort(key=lambda c: sample_key(c["session_id"], sample_salt))
773
+ else:
774
+ raise FetchError(f"unknown order {order!r}")
775
+ chosen = kept[:max_sessions]
776
+ counts["selected"] = len(chosen)
777
+ return chosen, counts
778
+
779
+
780
+ # --- the fetch --------------------------------------------------------------------------------
781
+
782
+ def write_private(path, text):
783
+ """Write text to a file only the user can read, also before the text is in."""
784
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
785
+ try:
786
+ os.chmod(path, 0o600) # an existing file keeps its mode through os.open
787
+ f = os.fdopen(fd, "w", encoding="utf-8")
788
+ except BaseException:
789
+ os.close(fd)
790
+ raise
791
+ with f:
792
+ f.write(text)
793
+
794
+
795
+ def _private_dir(path):
796
+ if not os.path.isdir(path):
797
+ os.makedirs(path, mode=0o700)
798
+ os.chmod(path, 0o700)
799
+ return path
800
+
801
+
802
+ def _resumed(path):
803
+ """The sessions of an earlier fetch's batch file, or None (absent, or cut short)."""
804
+ try:
805
+ with open(path, encoding="utf-8") as fh:
806
+ got = json.load(fh)
807
+ except (OSError, ValueError):
808
+ return None
809
+ return got if isinstance(got, list) else None
810
+
811
+
812
+ def digest(ids):
813
+ return hashlib.sha256("\n".join(ids).encode("utf-8")).hexdigest()[:16]
814
+
815
+
816
+ def code_sha():
817
+ """Hash of the modules a fetch runs, the same in the skill and in a kit."""
818
+ here = os.path.dirname(os.path.abspath(__file__))
819
+ h = hashlib.sha256()
820
+ for name in ("fetch.py", "redact.py", "splits.py", "stdm.py"):
821
+ with open(os.path.join(here, name), "rb") as f:
822
+ h.update(name.encode() + b"\0" + f.read() + b"\0")
823
+ return h.hexdigest()[:16]
824
+
825
+
826
+ def find_candidates(f, agent, start, end):
827
+ """(candidates, how the agent was matched): by participant API name, else by the planner ids
828
+ of the agent's GenAiPlannerDefinitions. Matching nothing raises, with what the data space holds."""
829
+ cands = f.candidates(start, end, agent=agent)
830
+ if cands:
831
+ return cands, {"by": "agent_api_name"}
832
+ planners, seen, error = [], [], None
833
+ try:
834
+ planners, seen = f.planners(agent)
835
+ except FetchError as e:
836
+ error = str(e)
837
+ ids = []
838
+ for p in planners:
839
+ ids += [p["Id"]] + ([p["Id"][:15]] if len(p["Id"]) == 18 else [])
840
+ if ids:
841
+ cands = f.candidates(start, end, participant_ids=ids)
842
+ if cands:
843
+ return cands, {"by": "planner_id", "planners": sorted(p.get("DeveloperName") or "?" for p in planners)}
844
+ raise FetchError(diagnose(f, agent, start, end, ids, planners, seen, error))
845
+
846
+
847
+ def diagnose(f, agent, start, end, ids, planners, seen, planner_error):
848
+ """Why the agent filter matched nothing, from counts only."""
849
+ lines = [f"no session of agent {agent!r} started in [{start}, {end}] with a TURN interaction in data space "
850
+ f"{f.dataspace!r}; nothing fetched."]
851
+ if planner_error:
852
+ lines.append(f"Its planner ids could not be looked up: {planner_error}")
853
+ elif planners:
854
+ lines.append("Its planners (" + ", ".join(sorted(p.get("DeveloperName") or "?" for p in planners))
855
+ + ") matched no session either.")
856
+ else:
857
+ lines.append(f"No GenAiPlannerDefinition is named {agent} or {agent}_<n>"
858
+ + (" (named like it: " + ", ".join(seen[:10]) + ")." if seen else "."))
859
+ queries = {"byname": count_sql(PARTICIPANT, "ssot__AiAgentApiName__c = " + _lit(agent)),
860
+ "agents": agents_in_window_sql(start, end)}
861
+ if ids:
862
+ queries["byid"] = count_sql(PARTICIPANT, "ssot__ParticipantId__c IN " + _in(ids, "planner id"))
863
+ try:
864
+ got = f.query(queries, "diagnose")
865
+ except FetchError as e:
866
+ lines.append(f"(The diagnostic queries failed too: {e})")
867
+ return " ".join(lines)
868
+ lines.append(f"Participant rows of {agent!r} at any time: {col(got['byname'][0], 0) if got['byname'] else 0} "
869
+ "by API name" + (f", {col(got['byid'][0], 0) if got['byid'] else 0} by planner id." if ids else "."))
870
+ agents = sorted(((col(r, 0) or "(no API name)", int(float(col(r, 1) or 0))) for r in got["agents"]),
871
+ key=lambda a: (-a[1], a[0]))
872
+ if agents:
873
+ lines.append("Agents with sessions in the window: "
874
+ + ", ".join(f"{name} ({n})" for name, n in agents[:10]) + ("..." if len(agents) > 10 else "") + ".")
875
+ else:
876
+ lines.append("No session of any agent started in the window: widen --start/--end.")
877
+ return " ".join(lines)
878
+
879
+
880
+ OUTPUTS = ("details.json", "sessions.json", "fetch.json")
881
+
882
+
883
+ def clear_outputs(out_dir):
884
+ """Remove an earlier fetch's files from out_dir: only the names a fetch writes."""
885
+ for name in OUTPUTS:
886
+ p = os.path.join(out_dir, name)
887
+ if os.path.isfile(p):
888
+ os.remove(p)
889
+ for sub, pattern in (("batches", r"b_[0-9a-f]{16}\.json"), ("apex", r"\d{3}_[a-z]+\.apex")):
890
+ d = os.path.join(out_dir, sub)
891
+ if os.path.isdir(d):
892
+ for name in os.listdir(d):
893
+ if re.fullmatch(pattern, name):
894
+ os.remove(os.path.join(d, name))
895
+
896
+
897
+ def fetch(f, agent, start, end, out_dir=None, channels=None, exclude=frozenset(), split=None, val_fraction=None,
898
+ split_salt=SPLIT_SALT, order="hash", sample_salt=SAMPLE_SALT, max_sessions=MAX_SESSIONS,
899
+ batch_size=BATCH_SIZE, dry_run=False, log=lambda line: None, params=None):
900
+ """Find, select and fetch the agent's sessions; writes details.json, sessions.json and
901
+ fetch.json to out_dir (not with dry_run) and returns the bt.fetch/1 manifest."""
902
+ check_name(agent)
903
+ start, end = window_bound(start), window_bound(end, end=True)
904
+ if parse_time(start) > parse_time(end):
905
+ raise FetchError(f"--start {start} is after --end {end}")
906
+ if split is not None and not (val_fraction and 0 < val_fraction < 1):
907
+ raise FetchError("--split needs --val-fraction in (0, 1)")
908
+ if batch_size < 1 or max_sessions < 1:
909
+ raise FetchError("--batch-size and --max-sessions must be >= 1")
910
+
911
+ cands, matched = find_candidates(f, agent, start, end)
912
+ chosen, counts = select(cands, channels, exclude, split, val_fraction, split_salt, order, sample_salt,
913
+ max_sessions)
914
+ log(f"candidates: {counts['candidates']} sessions of {agent} in the window (matched by "
915
+ f"{matched['by'].replace('_', ' ')})")
916
+ log(" by channel: " + ", ".join(f"{k} {v}" for k, v in counts["by_channel"].items()))
917
+ log(f" dropped: {counts['dropped_channel']} by channel, {counts['excluded']} excluded, "
918
+ f"{counts['other_split']} in the other split; {counts['available']} available, {counts['selected']} selected")
919
+ if not chosen:
920
+ raise FetchError("no session left to fetch after the channel, exclusion and split filters "
921
+ "(see the counts above)")
922
+ ids = [c["session_id"] for c in chosen]
923
+ manifest = {"schema": SCHEMA_FETCH, "org": f.org, "dataspace": f.dataspace, "agent": agent, "window": [start, end],
924
+ "agent_filter": matched,
925
+ "selection": {"channels": list(channels) if channels else None, "order": order,
926
+ "sample_salt": sample_salt if order == "hash" else None, "max_sessions": max_sessions,
927
+ "split": None if split is None else {"split": split, "val_fraction": val_fraction,
928
+ "salt": split_salt},
929
+ "excluded_ids": len(exclude)},
930
+ "params": params or {}, "counts": counts, "digests": {"selection": digest(ids)},
931
+ "code": {"fetch_sha256": code_sha()}, "dry_run": dry_run}
932
+ if f.names is not None:
933
+ manifest["stdm_names"] = f.names.manifest()
934
+ if dry_run:
935
+ return manifest
936
+
937
+ batch_dir = _private_dir(os.path.join(out_dir, "batches"))
938
+ convos, stats = [], {"batches": 0, "retries": 0, "resumed": 0}
939
+ pending = [ids[i:i + batch_size] for i in range(0, len(ids), batch_size)]
940
+ while pending:
941
+ batch = pending.pop(0)
942
+ path = os.path.join(batch_dir, "b_" + digest(batch) + ".json")
943
+ got = _resumed(path) # an interrupted fetch got this batch already
944
+ if got is not None:
945
+ stats["resumed"] += 1
946
+ else:
947
+ try:
948
+ got = f.details(batch)
949
+ except BatchTooBig as e:
950
+ if len(batch) == 1:
951
+ raise FetchError(f"one session is too big for a single Apex run ({e}); fetch it another way, "
952
+ "or leave it out with --exclude-sessions") from None
953
+ half = (len(batch) + 1) // 2
954
+ pending[:0] = [batch[:half], batch[half:]]
955
+ stats["retries"] += 1
956
+ log(f" a batch of {len(batch)} sessions was too big ({str(e)[:120]}); halving it")
957
+ continue
958
+ write_private(path, json.dumps(got, ensure_ascii=False))
959
+ stats["batches"] += 1
960
+ convos.extend(got)
961
+ log(f" fetched {len(convos)}/{len(ids)} sessions")
962
+
963
+ no_turns = sum(1 for c in convos if not c["turns"])
964
+ turns = sum(len(c["turns"]) for c in convos)
965
+ llm = sum(1 for c in convos for t in c["turns"] for s in t["steps"] if s.get("step_type") == "LLM_STEP")
966
+ counts.update(fetched=len(convos), turns=turns, llm_steps=llm, no_turns=no_turns, apex_runs=f.runs, **stats)
967
+ if no_turns:
968
+ log(f" warning: {no_turns} sessions came back without turns")
969
+ details = json.dumps(convos, ensure_ascii=False, indent=1)
970
+ write_private(os.path.join(out_dir, "details.json"), details + "\n")
971
+ write_private(os.path.join(out_dir, "sessions.json"),
972
+ json.dumps([session_summary(c) for c in chosen], ensure_ascii=False, indent=1) + "\n")
973
+ manifest["digests"]["details"] = hashlib.sha256((details + "\n").encode("utf-8")).hexdigest()[:16]
974
+ manifest["fetched"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
975
+ write_private(os.path.join(out_dir, "fetch.json"), json.dumps(manifest, indent=2) + "\n")
976
+ for name in os.listdir(batch_dir): # merged: the batches are not needed any more
977
+ if re.fullmatch(r"b_[0-9a-f]{16}\.json", name):
978
+ os.remove(os.path.join(batch_dir, name))
979
+ if not os.listdir(batch_dir):
980
+ os.rmdir(batch_dir)
981
+ return manifest
982
+
983
+
984
+ def probe(f, agent=None, start=None, end=None):
985
+ """{key: row count, or the error} for every STDM DMO, plus the agent's participant rows and
986
+ the sessions in the window when given. Errors are caught in the Apex, one per query. With a
987
+ names map, also each query shape a fetch runs (SHAPES; 0 rows each), to check every name."""
988
+ queries = {key: count_sql(table) for key, table in PROBE_DMOS.items()}
989
+ if agent:
990
+ queries["agent"] = count_sql(PARTICIPANT, "ssot__AiAgentApiName__c = " + _lit(check_name(agent)))
991
+ if start and end:
992
+ queries["window"] = count_sql(SESSION, "ssot__StartTimestamp__c >= " + _lit(window_bound(start))
993
+ + " AND ssot__StartTimestamp__c <= " + _lit(window_bound(end, end=True)))
994
+ if f.names is not None:
995
+ queries.update(shape_queries())
996
+ found = f.run(query_script(queries, f.dataspace, probe=True, names=f.names), "probe")
997
+ if END not in found:
998
+ raise FetchError("probe: the Apex log has no end marker")
999
+ out = {}
1000
+ for key in queries:
1001
+ page = found.get(f"STDM_RESULT_{key}_0")
1002
+ if not isinstance(page, dict):
1003
+ out[key] = {"error": "no result line"}
1004
+ elif "error" in page:
1005
+ out[key] = {"error": mask_ids(str(page["error"]))[:500]}
1006
+ else:
1007
+ rows = page.get("rows") or []
1008
+ if key in SHAPES: # a query shape: the rows of its first page, not a count
1009
+ out[key] = {"rows": len(rows)}
1010
+ else:
1011
+ out[key] = {"rows": int(float(col(rows[0], 0) or 0)) if rows else 0}
1012
+ return out