@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.
- package/README.md +55 -5
- package/package.json +2 -4
- package/skills/agentforce-offline-experimentation-run/SKILL.md +26 -49
- package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
- package/skills/commerce-react-storefront-create/SKILL.md +350 -0
- package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +2 -2
- package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +28 -16
- package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +18 -1
- package/skills/dx-lifecycle-center/SKILL.md +5 -2
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
- package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
- package/skills/dx-lifecycle-center/references/deploy.md +2 -2
- package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
- package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
- package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
- package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
- package/skills/dx-org-devhub-configure/SKILL.md +85 -84
- package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
- package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
- package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
- package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
- package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
- package/skills/dx-scratch-def-configure/SKILL.md +163 -0
- package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
- package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
- package/skills/dx-scratch-def-configure/references/features.json +3270 -0
- package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
- package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
- package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
- package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
- package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
- package/skills/education-cloud-domain-configure/SKILL.md +22 -24
- package/skills/education-cloud-domain-configure/references/dependent-features.md +9 -0
- package/skills/education-cloud-domain-configure/references/domain-features.md +15 -0
- package/skills/education-cloud-domain-configure/references/gotchas-extended.md +8 -0
- package/skills/education-cloud-domain-configure/references/setup-widgets.md +123 -0
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
- package/skills/experience-design-validate/SKILL.md +1 -1
- package/skills/experience-search-coordinate/SKILL.md +19 -13
- package/skills/experience-search-coordinate/references/content-route.md +79 -1
- package/skills/experience-search-coordinate/references/media-route.md +30 -4
- package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -1
- package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -1
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
- package/skills/platform-apex-generate/SKILL.md +14 -11
- package/skills/platform-apex-test-generate/SKILL.md +2 -1
- package/skills/platform-datamask-run/SKILL.md +52 -71
- package/skills/platform-datamask-run/references/api-surface.md +10 -4
- package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
- package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
- package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
- package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
- package/skills/platform-metadata-deploy/README.md +1 -1
- package/skills/platform-metadata-deploy/SKILL.md +114 -15
- package/skills/platform-metadata-deploy/references/agent-deployment-guide.md +3 -4
- package/skills/platform-metadata-deploy/references/orchestration.md +2 -3
- package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
- package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
- package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
- package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
- package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
- package/skills/platform-policy-rule-generate/SKILL.md +84 -232
- package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
- package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
- package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
- package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
- package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
- package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
- package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
- package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
- package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
- package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
- package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
- package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
- package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
- package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
- package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
- package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
- package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
- package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
- package/skills/platform-policy-rule-generate/references/templates.md +2 -2
- package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
- package/skills/platform-report-generate/SKILL.md +9 -4
- package/skills/platform-report-generate/references/chart-types.md +26 -2
- package/skills/platform-report-generate/references/column-names.md +43 -0
- package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
- package/skills/platform-report-generate/references/verification-checklist.md +10 -2
- package/skills/platform-value-set-generate/SKILL.md +19 -19
- package/skills/platform-widget-generate/SKILL.md +25 -51
- package/skills/platform-widget-generate/examples/formulas.json +19 -2
- package/skills/platform-widget-generate/examples/localized-labels.json +57 -0
- package/skills/platform-widget-generate/references/widget-actions.md +29 -0
- package/skills/platform-widget-generate/references/widget-bundle-validation.md +1 -0
- package/skills/platform-widget-generate/references/widget-formulas.md +24 -7
- package/skills/platform-widget-generate/references/widget-gotchas.md +10 -0
- package/skills/platform-widget-generate/references/widget-labels.md +78 -0
- package/skills/platform-widget-generate/references/widget-styling.md +12 -0
- package/skills/sales-call-scoring-configure/SKILL.md +17 -10
- package/skills/sales-call-scoring-configure/scripts/enable-call-scoring.sh +55 -33
- package/skills/service-digital-engagement-channel-configure/SKILL.md +11 -7
- package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
- package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +123 -16
- package/skills/service-digital-engagement-deployment-configure/SKILL.md +37 -100
- package/skills/service-digital-engagement-deployment-configure/assets/esd_web_update_template.xml +83 -8
- package/skills/service-digital-engagement-deployment-configure/examples/esd_web_prechat_fields.xml +74 -0
- package/skills/service-digital-engagement-deployment-configure/references/connect_api_creation.md +13 -3
- package/skills/service-digital-engagement-deployment-configure/references/deployment_settings.md +13 -78
- package/skills/service-digital-engagement-deployment-configure/references/onboarding_contract.md +5 -6
- package/skills/service-digital-engagement-deployment-configure/references/prechat_forms.md +245 -0
- package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +164 -120
- package/skills/service-itsm-agentic-setup-incident-management/examples/output-templates.md +61 -37
- package/skills/service-itsm-agentic-setup-incident-management/references/audit-tracking.md +42 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/broadcast-communications.md +23 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/email-to-incident.md +23 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-dashboards.md +25 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-persona-psg.md +30 -146
- package/skills/service-itsm-agentic-setup-incident-management/references/incident-preferences.md +32 -136
- package/skills/service-itsm-agentic-setup-incident-management/references/major-incident-management.md +32 -155
- package/skills/service-itsm-agentic-setup-incident-management/references/manage-incidents-with-ai.md +45 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/priority-matrix.md +32 -0
- package/skills/service-itsm-agentic-setup-incident-management/references/service-mgmt-privilege.md +30 -140
- package/skills/service-itsm-agentic-setup-incident-management/references/sla-milestones.md +110 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""SFAP /chat/generations client and the production request shape.
|
|
2
|
+
|
|
3
|
+
The token is read from its 0600 file on every call: it never enters argv, the environment
|
|
4
|
+
of child processes, or any artifact, and a token refreshed in place is picked up without a
|
|
5
|
+
restart. On a 401, on_unauthorized(token_used) may refresh it (tokens.TokenRefresher); a True
|
|
6
|
+
return sends the request once more. Failures come back as status 'infra_error' after
|
|
7
|
+
retries, never as an answer.
|
|
8
|
+
|
|
9
|
+
A timeout is retried once only: each one costs the whole timeout, and a model that runs away on a
|
|
10
|
+
request runs away again. The provider has its own limit (EinsteinBYOM: ~180 s, then a 502 whose
|
|
11
|
+
body says "Timeout"), recorded as provider_timeout; the default --timeout is above it, so that the
|
|
12
|
+
provider, not the client, cuts a slow answer off.
|
|
13
|
+
|
|
14
|
+
Production request fields (from captured gateway payloads) reproduced here:
|
|
15
|
+
tools [{"function": {name, description, parameters}}] (no "type")
|
|
16
|
+
tool_config {"mode": "auto", "allowed_tools": [all tools], "parallel_calls": true}
|
|
17
|
+
generation_settings {"num_generations": 1, penalties 0, "stop_sequences": [],
|
|
18
|
+
"parameters": {"seed": n}} — no max_tokens, no temperature
|
|
19
|
+
system_prompt_strategy "use_model_parameter"; enable_pii_masking false
|
|
20
|
+
Not reproduced: safety/bias scoring flags, trust settings, localization, turn ids, tags.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import copy
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import socket
|
|
27
|
+
import time
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
from .redact import redact, register_secret
|
|
32
|
+
from .units import parse_arguments
|
|
33
|
+
|
|
34
|
+
RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
|
35
|
+
# No answer in time: the client's timeout, or the provider's (a 502/504 whose body says so).
|
|
36
|
+
TIMEOUT_KINDS = ("timeout", "provider_timeout")
|
|
37
|
+
DEFAULT_TIMEOUT = 200
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sanitize_tools_for_sfap(tools):
|
|
41
|
+
"""Drop additionalProperties and empty `required` (SFAP has rejected both with a 400)."""
|
|
42
|
+
sanitized = []
|
|
43
|
+
for tool in tools:
|
|
44
|
+
tool = copy.deepcopy(tool)
|
|
45
|
+
params = (tool.get("function") or {}).get("parameters") or {}
|
|
46
|
+
params.pop("additionalProperties", None)
|
|
47
|
+
if "required" in params and not params["required"]:
|
|
48
|
+
del params["required"]
|
|
49
|
+
sanitized.append(tool)
|
|
50
|
+
return sanitized
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_sfap_tools(tools):
|
|
54
|
+
"""Step tool definitions (gen_ai.request.tools) in the production request shape."""
|
|
55
|
+
return [{"function": t.get("function") or {}} for t in sanitize_tools_for_sfap(tools)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_payload(model, messages, tools=None, seed=None, parameters=None):
|
|
59
|
+
payload = {"model": model, "messages": messages}
|
|
60
|
+
if tools:
|
|
61
|
+
payload["tools"] = to_sfap_tools(tools)
|
|
62
|
+
payload["tool_config"] = {
|
|
63
|
+
"mode": "auto",
|
|
64
|
+
"allowed_tools": [{"name": (t.get("function") or {}).get("name"), "type": "function"}
|
|
65
|
+
for t in tools],
|
|
66
|
+
"parallel_calls": True,
|
|
67
|
+
}
|
|
68
|
+
gen_params = {}
|
|
69
|
+
if seed is not None:
|
|
70
|
+
gen_params["seed"] = seed
|
|
71
|
+
gen_params.update(parameters or {})
|
|
72
|
+
payload["generation_settings"] = {
|
|
73
|
+
"num_generations": 1, "frequency_penalty": 0.0, "presence_penalty": 0.0,
|
|
74
|
+
"stop_sequences": [], "parameters": gen_params,
|
|
75
|
+
}
|
|
76
|
+
payload["system_prompt_strategy"] = "use_model_parameter"
|
|
77
|
+
payload["enable_pii_masking"] = False
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def request_sha(messages, tools):
|
|
82
|
+
"""Identity of a request's content (messages + tools), independent of model and seed."""
|
|
83
|
+
blob = json.dumps({"messages": messages, "tools": tools}, sort_keys=True,
|
|
84
|
+
separators=(",", ":"), ensure_ascii=False)
|
|
85
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _usage(body):
|
|
89
|
+
gd = body.get("generation_details") or {}
|
|
90
|
+
for holder in (gd.get("parameters"), body.get("parameters"), body):
|
|
91
|
+
if isinstance(holder, dict) and isinstance(holder.get("usage"), dict):
|
|
92
|
+
return holder["usage"]
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_generation(body):
|
|
97
|
+
"""{text, tool_calls, finish_reason, usage} from a 200 body, or None if it has no generation."""
|
|
98
|
+
gens = ((body or {}).get("generation_details") or {}).get("generations") or []
|
|
99
|
+
if not gens:
|
|
100
|
+
return None
|
|
101
|
+
g = gens[0]
|
|
102
|
+
calls = []
|
|
103
|
+
for inv in g.get("tool_invocations") or []:
|
|
104
|
+
fn = inv.get("function") or {}
|
|
105
|
+
args, raw = parse_arguments(fn.get("arguments"))
|
|
106
|
+
call = {"name": fn.get("name"), "args": args}
|
|
107
|
+
if raw is not None:
|
|
108
|
+
call["args_raw"] = raw
|
|
109
|
+
calls.append(call)
|
|
110
|
+
params = g.get("parameters") if isinstance(g.get("parameters"), dict) else {}
|
|
111
|
+
return {"text": g.get("content") or "", "tool_calls": calls,
|
|
112
|
+
"finish_reason": params.get("finish_reason") or g.get("finish_reason"),
|
|
113
|
+
"usage": _usage(body)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SfapClient:
|
|
117
|
+
def __init__(self, base_url, token_file, core_tenant_id, timeout=DEFAULT_TIMEOUT, max_retries=3,
|
|
118
|
+
backoff=2.0, rate_limit_backoff=10.0, timeout_retries=1, feature_id="AgentBacktesting",
|
|
119
|
+
app_context="EinsteinGPT", opener=None, sleep=time.sleep, on_unauthorized=None):
|
|
120
|
+
missing = [n for n, v in (("base_url", base_url), ("token_file", token_file),
|
|
121
|
+
("core_tenant_id", core_tenant_id)) if not v]
|
|
122
|
+
if missing:
|
|
123
|
+
raise ValueError("SFAP config missing: " + ", ".join(missing)
|
|
124
|
+
+ " (run scripts/resolve-token.sh and pass --token-info)")
|
|
125
|
+
self.url = base_url.rstrip("/") + "/chat/generations"
|
|
126
|
+
self.token_file = token_file
|
|
127
|
+
self.core_tenant_id = core_tenant_id
|
|
128
|
+
self.timeout = timeout
|
|
129
|
+
self.max_retries = max_retries
|
|
130
|
+
self.backoff = backoff
|
|
131
|
+
self.rate_limit_backoff = rate_limit_backoff
|
|
132
|
+
self.timeout_retries = timeout_retries
|
|
133
|
+
self.feature_id = feature_id
|
|
134
|
+
self.app_context = app_context
|
|
135
|
+
self._open = (opener.open if opener else urllib.request.urlopen)
|
|
136
|
+
self._sleep = sleep
|
|
137
|
+
self._on_unauthorized = on_unauthorized
|
|
138
|
+
|
|
139
|
+
def _token(self):
|
|
140
|
+
with open(self.token_file) as f:
|
|
141
|
+
token = f.read().strip()
|
|
142
|
+
if not token:
|
|
143
|
+
raise RuntimeError("SFAP token file is empty")
|
|
144
|
+
register_secret(token)
|
|
145
|
+
return token
|
|
146
|
+
|
|
147
|
+
def _delay(self, attempt, retry_after=None, http_status=None):
|
|
148
|
+
if retry_after:
|
|
149
|
+
try:
|
|
150
|
+
return min(60.0, float(retry_after))
|
|
151
|
+
except ValueError:
|
|
152
|
+
pass
|
|
153
|
+
if http_status == 429: # a quota, usually per minute: a few seconds' wait meets it again
|
|
154
|
+
return min(60.0, self.rate_limit_backoff * (2 ** (attempt - 1)))
|
|
155
|
+
return min(30.0, self.backoff * (2 ** (attempt - 1)))
|
|
156
|
+
|
|
157
|
+
def post(self, payload):
|
|
158
|
+
"""POST once per attempt. Returns {status, http_status, body, error, error_kind, attempts, latency_ms}."""
|
|
159
|
+
data = json.dumps(payload).encode("utf-8")
|
|
160
|
+
attempts, timeouts, refreshed, t0 = 0, 0, False, time.monotonic()
|
|
161
|
+
while True:
|
|
162
|
+
attempts += 1
|
|
163
|
+
err_kind, err, http_status, retry_after = None, None, None, None
|
|
164
|
+
try:
|
|
165
|
+
token = self._token()
|
|
166
|
+
except (OSError, RuntimeError) as e:
|
|
167
|
+
return self._fail("token", f"cannot read the SFAP token file: {type(e).__name__}",
|
|
168
|
+
None, attempts, t0)
|
|
169
|
+
try:
|
|
170
|
+
req = urllib.request.Request(self.url, data=data, method="POST", headers={
|
|
171
|
+
"Authorization": f"Bearer {token}",
|
|
172
|
+
"Content-Type": "application/json",
|
|
173
|
+
"x-sfdc-app-context": self.app_context,
|
|
174
|
+
"x-client-feature-id": self.feature_id,
|
|
175
|
+
"x-sfdc-core-tenant-id": self.core_tenant_id,
|
|
176
|
+
})
|
|
177
|
+
with self._open(req, timeout=self.timeout) as resp:
|
|
178
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
179
|
+
http_status = resp.status
|
|
180
|
+
try:
|
|
181
|
+
body = json.loads(raw)
|
|
182
|
+
except json.JSONDecodeError:
|
|
183
|
+
return self._fail("bad_json", f"HTTP {http_status}: response is not JSON: {redact(raw)[:200]}",
|
|
184
|
+
http_status, attempts, t0)
|
|
185
|
+
return {"status": "ok", "http_status": http_status, "body": body, "error": None,
|
|
186
|
+
"error_kind": None, "attempts": attempts,
|
|
187
|
+
"latency_ms": int((time.monotonic() - t0) * 1000)}
|
|
188
|
+
except urllib.error.HTTPError as e:
|
|
189
|
+
http_status = e.code
|
|
190
|
+
retry_after = e.headers.get("Retry-After") if e.headers else None
|
|
191
|
+
try:
|
|
192
|
+
body = e.read().decode("utf-8", "replace")
|
|
193
|
+
except Exception:
|
|
194
|
+
body = ""
|
|
195
|
+
finally:
|
|
196
|
+
e.close()
|
|
197
|
+
err_kind, err = f"http_{e.code}", f"HTTP {e.code}: {redact(body)[:300]}" # redact before cutting
|
|
198
|
+
if e.code in (502, 504) and "timeout" in body.lower():
|
|
199
|
+
err_kind = "provider_timeout"
|
|
200
|
+
if e.code == 401 and self._on_unauthorized and not refreshed:
|
|
201
|
+
refreshed = True
|
|
202
|
+
if self._on_unauthorized(token):
|
|
203
|
+
continue
|
|
204
|
+
if e.code not in RETRY_STATUS:
|
|
205
|
+
return self._fail(err_kind, err, http_status, attempts, t0)
|
|
206
|
+
except (socket.timeout, TimeoutError):
|
|
207
|
+
err_kind, err = "timeout", f"timeout after {self.timeout}s"
|
|
208
|
+
except urllib.error.URLError as e:
|
|
209
|
+
if isinstance(e.reason, (socket.timeout, TimeoutError)):
|
|
210
|
+
err_kind, err = "timeout", f"timeout after {self.timeout}s"
|
|
211
|
+
else:
|
|
212
|
+
err_kind, err = "network", f"network error: {e.reason}"
|
|
213
|
+
except (ConnectionError, OSError) as e:
|
|
214
|
+
err_kind, err = "network", f"network error: {e}"
|
|
215
|
+
if err_kind in TIMEOUT_KINDS:
|
|
216
|
+
timeouts += 1
|
|
217
|
+
if attempts > self.max_retries or timeouts > self.timeout_retries:
|
|
218
|
+
return self._fail(err_kind, err, http_status, attempts, t0)
|
|
219
|
+
self._sleep(self._delay(attempts, retry_after, http_status))
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _fail(kind, err, http_status, attempts, t0):
|
|
223
|
+
return {"status": "infra_error", "http_status": http_status, "body": None,
|
|
224
|
+
"error": redact(err), "error_kind": kind, "attempts": attempts,
|
|
225
|
+
"latency_ms": int((time.monotonic() - t0) * 1000)}
|
|
226
|
+
|
|
227
|
+
def generate(self, payload):
|
|
228
|
+
"""post() plus parsing; a 200 without a generation is an infra error too."""
|
|
229
|
+
res = self.post(payload)
|
|
230
|
+
if res["status"] != "ok":
|
|
231
|
+
res["response"] = None
|
|
232
|
+
return res
|
|
233
|
+
parsed = parse_generation(res["body"])
|
|
234
|
+
if parsed is None:
|
|
235
|
+
res.update(status="infra_error", error_kind="empty_response",
|
|
236
|
+
error="HTTP 200 without generation_details.generations")
|
|
237
|
+
res["response"] = parsed
|
|
238
|
+
return res
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Session splits (--val-fraction, --split) and excluded sessions (--exclude-sessions).
|
|
2
|
+
|
|
3
|
+
A unit's split depends on its session alone: sha256 of the salt and the session ID, read as a
|
|
4
|
+
number in [0, 1), gives "val" below the val fraction and "test" otherwise. So every step of a
|
|
5
|
+
session lands in one split, a session keeps its split when other sessions are added or
|
|
6
|
+
removed, and another salt gives an independent split.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import hashlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections import Counter
|
|
15
|
+
|
|
16
|
+
from . import stdm
|
|
17
|
+
|
|
18
|
+
DEFAULT_SALT = "bt-split/1"
|
|
19
|
+
SPLITS = ("test", "val")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SplitError(ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def session_split(session_id, val_fraction, salt=DEFAULT_SALT):
|
|
27
|
+
h = hashlib.sha256(f"{salt}\0{session_id}".encode("utf-8")).hexdigest()
|
|
28
|
+
return "val" if int(h[:13], 16) / 16 ** 13 < val_fraction else "test"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def label(units, val_fraction, salt=DEFAULT_SALT):
|
|
32
|
+
"""Set each unit's split; returns {split: [sessions, units]}. Units that already carry
|
|
33
|
+
another split (an earlier run's labels) are refused: relabelling would mix the sets."""
|
|
34
|
+
new = [session_split(u["session_id"], val_fraction, salt) for u in units]
|
|
35
|
+
clash = sum(1 for u, s in zip(units, new) if u.get("split") not in (None, s))
|
|
36
|
+
if clash:
|
|
37
|
+
raise SplitError(f"{clash} units already carry another split (labelled with another salt or "
|
|
38
|
+
"fraction); use those, or split anew from --details")
|
|
39
|
+
for u, s in zip(units, new):
|
|
40
|
+
u["split"] = s
|
|
41
|
+
sessions = {u["session_id"]: u["split"] for u in units}
|
|
42
|
+
counts = Counter(sessions.values())
|
|
43
|
+
return {s: [counts[s], new.count(s)] for s in SPLITS}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_session_ids(path):
|
|
47
|
+
"""Session IDs from a file that names sessions:
|
|
48
|
+
- one ID per line (blank lines and '#' comments skipped);
|
|
49
|
+
- JSONL with a session_id on each line: units.jsonl, results.jsonl;
|
|
50
|
+
- JSON: a list of objects with a session_id (bt_fetch's sessions.json or details.json, the
|
|
51
|
+
Apex class's output, also as an `sf apex run --json` file), or the old replay's
|
|
52
|
+
replay_results.json. Not a list of strings: step and generation IDs look like session IDs;
|
|
53
|
+
- CSV with a session_id column: replay_results_sfap.csv, the old replay's too.
|
|
54
|
+
Any other file is refused, so that it never passes as a list of IDs that excludes nothing. So is
|
|
55
|
+
an ID that is not letters, digits, '_', '.' and '-': matched exactly, " s1" would exclude nothing.
|
|
56
|
+
"""
|
|
57
|
+
with open(path, encoding="utf-8-sig") as f: # -sig: a CSV saved by Excel starts with a BOM
|
|
58
|
+
text = f.read()
|
|
59
|
+
lines = [(n, s.strip()) for n, s in enumerate(text.split("\n"), 1)]
|
|
60
|
+
lines = [(n, s) for n, s in lines if s and not s.startswith("#")]
|
|
61
|
+
if not lines:
|
|
62
|
+
return set()
|
|
63
|
+
first = lines[0][1]
|
|
64
|
+
if first[0] in "[{":
|
|
65
|
+
try:
|
|
66
|
+
doc = json.loads(text)
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
if first[0] == "[":
|
|
69
|
+
raise SplitError(f"not valid JSON ({e})") from None
|
|
70
|
+
return _jsonl_ids(lines)
|
|
71
|
+
return _document_ids(doc)
|
|
72
|
+
if "," in first or first == "session_id":
|
|
73
|
+
return _csv_ids(text)
|
|
74
|
+
for n, s in lines:
|
|
75
|
+
if not _SESSION_ID.match(s):
|
|
76
|
+
raise SplitError(f"line {n} is not a session ID, and the file is not JSON, JSONL or CSV")
|
|
77
|
+
return {s for _, s in lines}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_SESSION_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _jsonl_ids(lines):
|
|
84
|
+
ids = set()
|
|
85
|
+
for n, s in lines:
|
|
86
|
+
try:
|
|
87
|
+
row = json.loads(s)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise SplitError(f"line {n}: not JSON") from None
|
|
90
|
+
ids.add(_session_id(row.get("session_id") if isinstance(row, dict) else None, f"line {n}"))
|
|
91
|
+
return ids
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _document_ids(doc):
|
|
95
|
+
if isinstance(doc, list):
|
|
96
|
+
items = doc
|
|
97
|
+
elif not isinstance(doc, dict):
|
|
98
|
+
raise SplitError("JSON that is neither a list nor an object")
|
|
99
|
+
else:
|
|
100
|
+
try:
|
|
101
|
+
found = stdm.parse_sf_output(doc, "sf apex run output")
|
|
102
|
+
except stdm.StdmError as e:
|
|
103
|
+
raise SplitError(str(e)) from None
|
|
104
|
+
if found is not None: # an `sf apex run --json` file
|
|
105
|
+
if not found:
|
|
106
|
+
raise SplitError("an sf apex run output without an STDM_RESULT line")
|
|
107
|
+
items = [x for payload in found.values() for x in (payload if isinstance(payload, list) else [payload])]
|
|
108
|
+
elif isinstance(doc.get("rows"), list): # the old replay's replay_results.json
|
|
109
|
+
items = [{"session_id": r.get("AiAgentSessionId")} if isinstance(r, dict) else r for r in doc["rows"]]
|
|
110
|
+
else:
|
|
111
|
+
items = [doc]
|
|
112
|
+
ids = set()
|
|
113
|
+
for i, item in enumerate(items, 1):
|
|
114
|
+
if isinstance(item, str):
|
|
115
|
+
raise SplitError(f"item {i} is a string, not an object with a session_id (step IDs look the "
|
|
116
|
+
"same); write a list of session IDs one per line")
|
|
117
|
+
ids.add(_session_id(item.get("session_id") if isinstance(item, dict) else None, f"item {i}"))
|
|
118
|
+
return ids
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _csv_ids(text):
|
|
122
|
+
rows = csv.DictReader(io.StringIO(text))
|
|
123
|
+
if "session_id" not in (rows.fieldnames or ()):
|
|
124
|
+
raise SplitError("a CSV without a session_id column")
|
|
125
|
+
ids = set()
|
|
126
|
+
for i, row in enumerate(rows, 1):
|
|
127
|
+
ids.add(_session_id((row.get("session_id") or "").strip(), f"CSV row {i}"))
|
|
128
|
+
return ids
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _session_id(value, where):
|
|
132
|
+
if not isinstance(value, str) or not value:
|
|
133
|
+
raise SplitError(f"{where}: no session_id")
|
|
134
|
+
if not _SESSION_ID.match(value):
|
|
135
|
+
raise SplitError(f"{where}: its session_id is not a session ID")
|
|
136
|
+
return value
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Read STDM query results.
|
|
2
|
+
|
|
3
|
+
The skill's fetch phases run Apex with `sf apex run --json` and print each result as one
|
|
4
|
+
debug line, `...|DEBUG|STDM_RESULT:<json>` (or `STDM_RESULT_<suffix>:` when one script
|
|
5
|
+
prints several). The source echo of the script also contains `STDM_RESULT:`, which is why
|
|
6
|
+
only the `|DEBUG|` form is matched. Inside a debug message the log writes '|' as '|';
|
|
7
|
+
that is undone before parsing (the old skill parser kept the entity, e.g. in prompts).
|
|
8
|
+
bt_fetch.py's scripts print several pages per query, `STDM_RESULT_<query>_<page>:`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .redact import redact
|
|
15
|
+
|
|
16
|
+
_MARKER = re.compile(r"\|DEBUG\|(STDM_RESULT(?:_[A-Za-z0-9_]+)?):")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StdmError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_apex_logs(logs):
|
|
24
|
+
"""{marker: parsed JSON} for every STDM_RESULT debug line in an Apex log."""
|
|
25
|
+
found = {}
|
|
26
|
+
for m in _MARKER.finditer(logs):
|
|
27
|
+
line_end = logs.find("\n", m.end())
|
|
28
|
+
raw = logs[m.end():] if line_end < 0 else logs[m.end():line_end]
|
|
29
|
+
# The debug log escapes its own field separator inside messages: '|' -> '|'.
|
|
30
|
+
raw = raw.strip().replace("|", "|")
|
|
31
|
+
try:
|
|
32
|
+
found[m.group(1)] = json.loads(raw)
|
|
33
|
+
except json.JSONDecodeError as e:
|
|
34
|
+
raise StdmError(
|
|
35
|
+
f"{m.group(1)}: debug line is not valid JSON ({e.msg} at char {e.pos} of {len(raw)}). "
|
|
36
|
+
"The debug log was probably truncated (Apex caps a log at 20 MB); "
|
|
37
|
+
"fetch fewer sessions or steps per batch.") from None
|
|
38
|
+
return found
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_sf_output(data, where):
|
|
42
|
+
"""{marker: payload} from the parsed output of `sf apex run --json`: {} when its log has no
|
|
43
|
+
STDM_RESULT line, None when data is not that output. A run that failed to compile or threw,
|
|
44
|
+
and an error of the sf CLI itself (no org, expired session), raise StdmError."""
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
return None
|
|
47
|
+
res = data.get("result")
|
|
48
|
+
if isinstance(res, dict) and "logs" in res:
|
|
49
|
+
if res.get("success") is False or res.get("compiled") is False:
|
|
50
|
+
msg = str(res.get("compileProblem") or res.get("exceptionMessage") or "unknown error")
|
|
51
|
+
if res.get("compiled") is False and isinstance(res.get("line"), int) and res["line"] > 0:
|
|
52
|
+
msg += f" (line {res['line']}, column {res.get('column')})"
|
|
53
|
+
raise StdmError(f"{where}: Apex run failed: {redact(msg)[:300]}")
|
|
54
|
+
return parse_apex_logs(res.get("logs") or "")
|
|
55
|
+
if data.get("status") not in (None, 0) and isinstance(data.get("message"), str):
|
|
56
|
+
name = data.get("name")
|
|
57
|
+
raise StdmError(f"{where}: sf failed" + (f" ({name})" if isinstance(name, str) else "")
|
|
58
|
+
+ f": {redact(data['message'])[:300]}")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_result_file(path):
|
|
63
|
+
"""{marker: payload} from an `sf apex run --json` output file.
|
|
64
|
+
|
|
65
|
+
A file holding plain JSON (a list or an object without the sf CLI envelope) is
|
|
66
|
+
returned as {None: payload}, so already-extracted files work too.
|
|
67
|
+
"""
|
|
68
|
+
with open(path, encoding="utf-8") as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
found = parse_sf_output(data, path)
|
|
71
|
+
if found is None:
|
|
72
|
+
return {None: data}
|
|
73
|
+
if not found:
|
|
74
|
+
raise StdmError(f"{path}: no |DEBUG|STDM_RESULT line in the Apex log")
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_conversations(paths):
|
|
79
|
+
"""Merge ConversationData lists from one or more details files.
|
|
80
|
+
|
|
81
|
+
Accepts getMultipleConversationDetails output (a list), getConversationDetails output
|
|
82
|
+
(one object), or several markers per file. The first copy of a session wins.
|
|
83
|
+
Returns (conversations, duplicate_count).
|
|
84
|
+
"""
|
|
85
|
+
convos, seen, dupes = [], set(), 0
|
|
86
|
+
for path in paths:
|
|
87
|
+
for payload in load_result_file(path).values():
|
|
88
|
+
items = payload if isinstance(payload, list) else [payload]
|
|
89
|
+
for c in items:
|
|
90
|
+
if not isinstance(c, dict) or "turns" not in c or not isinstance(c.get("session_id"), str) \
|
|
91
|
+
or not c["session_id"].strip():
|
|
92
|
+
raise StdmError(f"{path}: expected ConversationData objects (a session_id, turns)")
|
|
93
|
+
if c["session_id"] in seen:
|
|
94
|
+
dupes += 1
|
|
95
|
+
continue
|
|
96
|
+
seen.add(c["session_id"])
|
|
97
|
+
convos.append(c)
|
|
98
|
+
return convos, dupes
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""SFAP token refresh (--token-org): mint a new token when the run's token expires.
|
|
2
|
+
|
|
3
|
+
An SFAP token lasts about two hours, so a long replay outlives it. On an HTTP 401 the client
|
|
4
|
+
calls the refresher with the token that was refused. The refresher runs
|
|
5
|
+
|
|
6
|
+
bash scripts/resolve-token.sh --org <alias>
|
|
7
|
+
|
|
8
|
+
which writes a new token to a new 0600 temp file and prints that file's path. The new token
|
|
9
|
+
replaces the old one in the run's token file (atomically, mode 0600), the script's file is
|
|
10
|
+
deleted, and the client sends the request again, once.
|
|
11
|
+
|
|
12
|
+
- One mint per expiry: requests refused with the same token wait for the first refresh, then
|
|
13
|
+
retry with its token. A lock file next to the token file (<token file>.lock, empty) extends
|
|
14
|
+
this to other processes reading the same file.
|
|
15
|
+
- No loops: a token minted here and refused within FRESH_SECONDS is not an expired token (wrong
|
|
16
|
+
org, missing scope), and a mint that failed is not repeated for the same token. Those requests
|
|
17
|
+
stay http_401 infra errors.
|
|
18
|
+
- The new token must be for the run's SFAP endpoint and core tenant: another org's is not used.
|
|
19
|
+
- The token never enters argv or output: the script gets the org alias only, and its stdout (a
|
|
20
|
+
file path and IDs) is parsed, never printed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import fcntl
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import signal
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
from .redact import redact, register_secret
|
|
35
|
+
|
|
36
|
+
RESOLVE_TOKEN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "resolve-token.sh")
|
|
37
|
+
FRESH_SECONDS = 300
|
|
38
|
+
SCRIPT_FILE_PREFIX = "offexp-jwt." # resolve-token.sh's temp files: the only files deleted here
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_token(path):
|
|
42
|
+
try:
|
|
43
|
+
with open(path) as f:
|
|
44
|
+
return f.read().strip()
|
|
45
|
+
except OSError:
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _digest(token):
|
|
50
|
+
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _last_json(out):
|
|
54
|
+
"""The script's JSON line, {} if there is none."""
|
|
55
|
+
for line in reversed((out or "").strip().splitlines()):
|
|
56
|
+
try:
|
|
57
|
+
obj = json.loads(line)
|
|
58
|
+
except ValueError:
|
|
59
|
+
continue
|
|
60
|
+
return obj if isinstance(obj, dict) else {}
|
|
61
|
+
return {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _print(msg):
|
|
65
|
+
print(msg, flush=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TokenRefresher:
|
|
69
|
+
"""SfapClient's on_unauthorized(token_used): True when the token file now holds a token worth a retry."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, token_file, org, base_url, core_tenant_id, script=None, timeout=180, log=_print,
|
|
72
|
+
clock=time.monotonic):
|
|
73
|
+
self.token_file = os.path.abspath(token_file)
|
|
74
|
+
self.org = org
|
|
75
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
76
|
+
self.core_tenant_id = core_tenant_id
|
|
77
|
+
self.script = script or RESOLVE_TOKEN
|
|
78
|
+
self.timeout = timeout
|
|
79
|
+
self.log = log
|
|
80
|
+
self._clock = clock
|
|
81
|
+
self.refreshes = self.failures = 0
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
self._minted = {} # digest of a token minted here -> when
|
|
84
|
+
self._given_up = set() # digests of tokens not to refresh again
|
|
85
|
+
|
|
86
|
+
def __call__(self, token_used):
|
|
87
|
+
used = _digest(token_used)
|
|
88
|
+
with self._lock:
|
|
89
|
+
try:
|
|
90
|
+
with self._file_lock():
|
|
91
|
+
if read_token(self.token_file) not in ("", token_used):
|
|
92
|
+
return True # refreshed meanwhile, by another thread or process
|
|
93
|
+
if used in self._given_up:
|
|
94
|
+
return False
|
|
95
|
+
minted = self._minted.get(used)
|
|
96
|
+
if minted is not None and self._clock() - minted < FRESH_SECONDS:
|
|
97
|
+
why = "a token minted moments ago was refused too, so expiry is not the problem"
|
|
98
|
+
else:
|
|
99
|
+
why = self._mint()
|
|
100
|
+
except OSError as e:
|
|
101
|
+
why = f"cannot update the token file ({type(e).__name__})"
|
|
102
|
+
if why is None:
|
|
103
|
+
self.refreshes += 1
|
|
104
|
+
self.log(f"SFAP token refreshed (resolve-token.sh --org {self.org})")
|
|
105
|
+
return True
|
|
106
|
+
self._given_up.add(used)
|
|
107
|
+
self.failures += 1
|
|
108
|
+
self.log(f"WARNING: SFAP token not refreshed: {redact(why)}. The calls refused with HTTP 401 are "
|
|
109
|
+
"infra errors; with --cache-dir a rerun sends only those")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def _mint(self):
|
|
113
|
+
"""Run the script and install its token: None on success, else why not."""
|
|
114
|
+
try:
|
|
115
|
+
proc = subprocess.Popen(["bash", self.script, "--org", self.org], stdin=subprocess.DEVNULL,
|
|
116
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
117
|
+
universal_newlines=True, start_new_session=True)
|
|
118
|
+
except OSError as e:
|
|
119
|
+
return f"cannot run resolve-token.sh ({type(e).__name__})"
|
|
120
|
+
try:
|
|
121
|
+
out, _ = proc.communicate(timeout=self.timeout)
|
|
122
|
+
except subprocess.TimeoutExpired:
|
|
123
|
+
with contextlib.suppress(OSError):
|
|
124
|
+
os.killpg(proc.pid, signal.SIGKILL) # the script's sf and curl children too
|
|
125
|
+
proc.communicate()
|
|
126
|
+
return f"resolve-token.sh --org {self.org} took more than {self.timeout:g}s"
|
|
127
|
+
info = _last_json(out)
|
|
128
|
+
new_file = info.get("tokenFile")
|
|
129
|
+
try:
|
|
130
|
+
if info.get("error"):
|
|
131
|
+
return f"resolve-token.sh --org {self.org}: {info['error']} ({info.get('hint') or 'no hint'})"
|
|
132
|
+
if proc.returncode or not new_file:
|
|
133
|
+
return f"resolve-token.sh --org {self.org} exited {proc.returncode} without a token file"
|
|
134
|
+
tenant = info.get("coreTenantId")
|
|
135
|
+
if (info.get("baseUrl") or "").rstrip("/") != self.base_url or (tenant and tenant != self.core_tenant_id):
|
|
136
|
+
return (f"resolve-token.sh --org {self.org} gives a token for another SFAP endpoint or core tenant "
|
|
137
|
+
"than the run's (is --token-org the org --token-info was made for?)")
|
|
138
|
+
token = read_token(new_file)
|
|
139
|
+
if not token:
|
|
140
|
+
return "resolve-token.sh wrote an empty token file"
|
|
141
|
+
register_secret(token)
|
|
142
|
+
self._install(token)
|
|
143
|
+
self._minted[_digest(token)] = self._clock()
|
|
144
|
+
return None
|
|
145
|
+
finally:
|
|
146
|
+
if (new_file and os.path.abspath(new_file) != self.token_file
|
|
147
|
+
and os.path.basename(new_file).startswith(SCRIPT_FILE_PREFIX) and os.path.isfile(new_file)):
|
|
148
|
+
os.unlink(new_file)
|
|
149
|
+
|
|
150
|
+
def _install(self, token):
|
|
151
|
+
"""Replace the token file atomically: a reader sees the old token or the new one, never a partial file."""
|
|
152
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.token_file), prefix=".bt-token-") # mode 0600
|
|
153
|
+
try:
|
|
154
|
+
with os.fdopen(fd, "w") as f:
|
|
155
|
+
f.write(token)
|
|
156
|
+
os.replace(tmp, self.token_file)
|
|
157
|
+
except BaseException:
|
|
158
|
+
with contextlib.suppress(OSError):
|
|
159
|
+
os.unlink(tmp)
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
@contextlib.contextmanager
|
|
163
|
+
def _file_lock(self):
|
|
164
|
+
fd = os.open(self.token_file + ".lock", os.O_RDWR | os.O_CREAT, 0o600)
|
|
165
|
+
try:
|
|
166
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
167
|
+
yield
|
|
168
|
+
finally:
|
|
169
|
+
os.close(fd) # releases the lock
|
|
170
|
+
|
|
171
|
+
def stats(self):
|
|
172
|
+
with self._lock:
|
|
173
|
+
return {"refreshes": self.refreshes, "failures": self.failures}
|