@salesforce/afv-skills 1.47.0 → 1.49.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 (160) hide show
  1. package/package.json +1 -1
  2. package/skills/education-cloud-domain-configure/SKILL.md +234 -0
  3. package/skills/education-cloud-domain-configure/references/dependent-features.md +198 -0
  4. package/skills/education-cloud-domain-configure/references/domain-features/academic-operations.md +386 -0
  5. package/skills/education-cloud-domain-configure/references/domain-features/alumni-relations.md +155 -0
  6. package/skills/education-cloud-domain-configure/references/domain-features/cross-domain-dependencies.md +18 -0
  7. package/skills/education-cloud-domain-configure/references/domain-features/industriessettings-toggle-reference.md +43 -0
  8. package/skills/education-cloud-domain-configure/references/domain-features/mentoring.md +198 -0
  9. package/skills/education-cloud-domain-configure/references/domain-features/recruitment-admissions.md +315 -0
  10. package/skills/education-cloud-domain-configure/references/domain-features/student-management.md +155 -0
  11. package/skills/education-cloud-domain-configure/references/domain-features/student-success.md +249 -0
  12. package/skills/education-cloud-domain-configure/references/domain-features.md +27 -0
  13. package/skills/education-cloud-domain-configure/references/gotchas-extended.md +23 -0
  14. package/skills/education-cloud-domain-configure/references/toggle-write-path.md +67 -0
  15. package/skills/education-cloud-domain-configure/scripts/check_license_provisioned.py +62 -0
  16. package/skills/education-cloud-domain-configure/scripts/check_org_edition.py +44 -0
  17. package/skills/education-cloud-domain-configure/scripts/check_permission_assigned.py +39 -0
  18. package/skills/education-cloud-multi-campus-configure/SKILL.md +2 -0
  19. package/skills/education-cloud-multi-campus-configure/references/foundation_prerequisites.md +4 -3
  20. package/skills/service-agentforce-channel-configure/SKILL.md +33 -28
  21. package/skills/service-agentforce-human-escalation-configure/SKILL.md +205 -0
  22. package/skills/service-agentforce-human-escalation-configure/assets/escalation-thresholds.instructions.md +37 -0
  23. package/skills/service-agentforce-human-escalation-configure/assets/force-app/main/default/flows/Human_Escalation_Outbound_Flow.flow-meta.xml +173 -0
  24. package/skills/service-agentforce-human-escalation-configure/assets/package.xml +8 -0
  25. package/skills/service-agentforce-human-escalation-configure/assets/sfdx-project.json +5 -0
  26. package/skills/service-agentforce-human-escalation-configure/references/classifier-contracts.md +103 -0
  27. package/skills/service-agentforce-human-escalation-configure/references/escalation-mechanism.md +63 -0
  28. package/skills/service-agentforce-human-escalation-configure/references/failure-policy.md +44 -0
  29. package/skills/service-agentforce-human-escalation-configure/references/nga-escalation.md +57 -0
  30. package/skills/service-agentforce-human-escalation-configure/references/runtime-verification.md +72 -0
  31. package/skills/service-agentforce-human-escalation-configure/scripts/classify-agent-active.mjs +49 -0
  32. package/skills/service-agentforce-human-escalation-configure/scripts/classify-nga-escalation.mjs +166 -0
  33. package/skills/service-agentforce-human-escalation-configure/scripts/extract-outbound-route.mjs +67 -0
  34. package/skills/service-agentforce-human-escalation-configure/scripts/patch-escalation-surfaces.mjs +131 -0
  35. package/skills/service-agentforce-human-escalation-configure/scripts/tests/__init__.py +0 -0
  36. package/skills/service-agentforce-human-escalation-configure/scripts/tests/_bootstrap.py +249 -0
  37. package/skills/service-agentforce-human-escalation-configure/scripts/tests/test_escalation_contracts.py +899 -0
  38. package/skills/service-agentforce-human-escalation-configure/scripts/verify-and-configure.sh +705 -0
  39. package/skills/service-agentforce-human-escalation-configure/scripts/verify-escalation-config.mjs +89 -0
  40. package/skills/service-de-channel-routing-configure/SKILL.md +13 -5
  41. package/skills/service-de-channel-routing-configure/scripts/tests/test_skill_contract.py +25 -0
  42. package/skills/service-itsm-agentic-setup-agentforce-coordinate/SKILL.md +23 -10
  43. package/skills/service-itsm-agentic-setup-agentforce-coordinate/examples/output-templates.md +63 -48
  44. package/skills/service-itsm-agentic-setup-agentforce-coordinate/scripts/verify-child-verdict.mjs +6 -2
  45. package/skills/service-omni-agent-users-create/SKILL.md +96 -0
  46. package/skills/service-omni-agent-users-create/assets/create-users.apex.template +80 -0
  47. package/skills/service-omni-agent-users-create/references/apex-patterns.md +135 -0
  48. package/skills/service-omni-agent-users-create/references/apex-template-notes.md +40 -0
  49. package/skills/service-omni-agent-users-create/scripts/detect-and-create.sh +206 -0
  50. package/skills/service-omni-agent-users-create/scripts/detect-existing.sh +122 -0
  51. package/skills/service-omni-agent-users-create/scripts/run-create.sh +298 -0
  52. package/skills/service-omni-agent-users-create/scripts/tests/test_user_create_security.py +171 -0
  53. package/skills/service-omni-agent-work-sharing-configure/SKILL.md +78 -0
  54. package/skills/service-omni-agent-work-sharing-configure/references/api-notes.md +31 -0
  55. package/skills/service-omni-agent-work-sharing-configure/scripts/configure-and-report.sh +186 -0
  56. package/skills/service-omni-agent-work-sharing-configure/scripts/tests/_bootstrap.py +120 -0
  57. package/skills/service-omni-agent-work-sharing-configure/scripts/tests/test_agent_work_sharing_contracts.py +93 -0
  58. package/skills/service-omni-base-settings-configure/SKILL.md +77 -0
  59. package/skills/service-omni-base-settings-configure/assets/force-app/main/default/settings/OmniChannel.settings-meta.xml +8 -0
  60. package/skills/service-omni-base-settings-configure/assets/package.xml +8 -0
  61. package/skills/service-omni-base-settings-configure/assets/sfdx-project.json +12 -0
  62. package/skills/service-omni-base-settings-configure/references/api-notes.md +121 -0
  63. package/skills/service-omni-base-settings-configure/scripts/configure-and-report.sh +164 -0
  64. package/skills/service-omni-base-settings-configure/scripts/detect-existing.sh +69 -0
  65. package/skills/service-omni-channel-setup-coordinate/SKILL.md +168 -0
  66. package/skills/service-omni-channel-setup-coordinate/examples/output-templates.md +161 -0
  67. package/skills/service-omni-channel-setup-coordinate/references/gap-catalog.md +99 -0
  68. package/skills/service-omni-channel-setup-coordinate/scripts/integration-driver.sh +1542 -0
  69. package/skills/service-omni-channel-setup-coordinate/scripts/tests/__init__.py +0 -0
  70. package/skills/service-omni-channel-setup-coordinate/scripts/tests/_bootstrap.py +223 -0
  71. package/skills/service-omni-channel-setup-coordinate/scripts/tests/test_omni_contracts.py +804 -0
  72. package/skills/service-omni-command-center-analyze/SKILL.md +84 -0
  73. package/skills/service-omni-command-center-analyze/references/api-notes.md +54 -0
  74. package/skills/service-omni-command-center-analyze/scripts/analyze.sh +198 -0
  75. package/skills/service-omni-command-center-analyze/scripts/tests/_bootstrap.py +115 -0
  76. package/skills/service-omni-command-center-analyze/scripts/tests/test_command_center_analyze_contracts.py +165 -0
  77. package/skills/service-omni-permission-set-assign/SKILL.md +88 -0
  78. package/skills/service-omni-permission-set-assign/assets/package.xml +8 -0
  79. package/skills/service-omni-permission-set-assign/references/api-notes.md +143 -0
  80. package/skills/service-omni-permission-set-assign/scripts/verify-and-assign.sh +397 -0
  81. package/skills/service-omni-presence-status-deploy/SKILL.md +87 -0
  82. package/skills/service-omni-presence-status-deploy/assets/package.xml +12 -0
  83. package/skills/service-omni-presence-status-deploy/references/api-notes.md +115 -0
  84. package/skills/service-omni-presence-status-deploy/scripts/deploy-and-report.sh +422 -0
  85. package/skills/service-omni-presence-user-config-deploy/SKILL.md +80 -0
  86. package/skills/service-omni-presence-user-config-deploy/references/api-notes.md +60 -0
  87. package/skills/service-omni-presence-user-config-deploy/scripts/deploy-and-report.sh +261 -0
  88. package/skills/service-omni-presence-user-config-deploy/scripts/tests/_bootstrap.py +106 -0
  89. package/skills/service-omni-presence-user-config-deploy/scripts/tests/test_presence_user_config_contracts.py +142 -0
  90. package/skills/service-omni-queue-deploy/SKILL.md +91 -0
  91. package/skills/service-omni-queue-deploy/references/api-notes.md +140 -0
  92. package/skills/service-omni-queue-deploy/scripts/verify-and-align.sh +470 -0
  93. package/skills/service-omni-queue-members-assign/SKILL.md +93 -0
  94. package/skills/service-omni-queue-members-assign/references/api-notes.md +144 -0
  95. package/skills/service-omni-queue-members-assign/scripts/tests/_bootstrap.py +67 -0
  96. package/skills/service-omni-queue-members-assign/scripts/tests/test_queue_members_contracts.py +73 -0
  97. package/skills/service-omni-queue-members-assign/scripts/verify-and-bind.sh +362 -0
  98. package/skills/service-omni-queue-routing-config-deploy/SKILL.md +101 -0
  99. package/skills/service-omni-queue-routing-config-deploy/references/api-notes.md +127 -0
  100. package/skills/service-omni-queue-routing-config-deploy/scripts/tests/_bootstrap.py +90 -0
  101. package/skills/service-omni-queue-routing-config-deploy/scripts/tests/test_queue_routing_config_contracts.py +70 -0
  102. package/skills/service-omni-queue-routing-config-deploy/scripts/upsert-and-report.sh +538 -0
  103. package/skills/service-omni-routing-flow-deploy/SKILL.md +111 -0
  104. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.SkillsBased.flow-meta.xml +94 -0
  105. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Case_Trigger.flow-meta.xml +88 -0
  106. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_Cases.flow-meta.xml +144 -0
  107. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.SkillsBased.flow-meta.xml +94 -0
  108. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCall_Trigger.flow-meta.xml +88 -0
  109. package/skills/service-omni-routing-flow-deploy/assets/force-app/main/default/flows/Omni_Route_VoiceCalls.flow-meta.xml +144 -0
  110. package/skills/service-omni-routing-flow-deploy/assets/package.xml +11 -0
  111. package/skills/service-omni-routing-flow-deploy/assets/sfdx-project.json +11 -0
  112. package/skills/service-omni-routing-flow-deploy/references/api-notes.md +265 -0
  113. package/skills/service-omni-routing-flow-deploy/scripts/deploy-and-report.sh +684 -0
  114. package/skills/service-omni-service-channel-configure/SKILL.md +83 -0
  115. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Cases.serviceChannel-meta.xml +9 -0
  116. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/Incidents.serviceChannel-meta.xml +9 -0
  117. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/MessagingSessions.serviceChannel-meta.xml +9 -0
  118. package/skills/service-omni-service-channel-configure/assets/force-app/main/default/serviceChannels/VoiceCalls.serviceChannel-meta.xml +9 -0
  119. package/skills/service-omni-service-channel-configure/assets/package.xml +11 -0
  120. package/skills/service-omni-service-channel-configure/assets/sfdx-project.json +12 -0
  121. package/skills/service-omni-service-channel-configure/references/api-notes.md +84 -0
  122. package/skills/service-omni-service-channel-configure/scripts/deploy-and-report.sh +383 -0
  123. package/skills/service-omni-sidebar-configure/SKILL.md +77 -0
  124. package/skills/service-omni-sidebar-configure/references/api-notes.md +51 -0
  125. package/skills/service-omni-sidebar-configure/scripts/enable-and-report.sh +315 -0
  126. package/skills/service-omni-sidebar-configure/scripts/tests/_bootstrap.py +142 -0
  127. package/skills/service-omni-sidebar-configure/scripts/tests/test_sidebar_contracts.py +139 -0
  128. package/skills/service-omni-skills-based-routing-configure/SKILL.md +81 -0
  129. package/skills/service-omni-skills-based-routing-configure/references/api-notes.md +50 -0
  130. package/skills/service-omni-skills-based-routing-configure/scripts/configure-and-report.sh +270 -0
  131. package/skills/service-omni-skills-based-routing-configure/scripts/tests/_bootstrap.py +120 -0
  132. package/skills/service-omni-skills-based-routing-configure/scripts/tests/test_sbr_contracts.py +129 -0
  133. package/skills/service-omni-supervisor-config-deploy/SKILL.md +97 -0
  134. package/skills/service-omni-supervisor-config-deploy/assets/force-app/main/default/omniSupervisorConfigs/Omni_Supervisor.omniSupervisorConfig-meta.xml +9 -0
  135. package/skills/service-omni-supervisor-config-deploy/assets/package.xml +8 -0
  136. package/skills/service-omni-supervisor-config-deploy/assets/sfdx-project.json +12 -0
  137. package/skills/service-omni-supervisor-config-deploy/references/api-notes.md +205 -0
  138. package/skills/service-omni-supervisor-config-deploy/scripts/deploy-and-report.sh +586 -0
  139. package/skills/service-omni-supervisor-config-deploy/scripts/tests/_bootstrap.py +153 -0
  140. package/skills/service-omni-supervisor-config-deploy/scripts/tests/test_supervisor_config_contracts.py +116 -0
  141. package/skills/service-omni-supervisor-permset-assign/SKILL.md +88 -0
  142. package/skills/service-omni-supervisor-permset-assign/references/api-notes.md +48 -0
  143. package/skills/service-omni-supervisor-permset-assign/scripts/verify-and-assign.sh +316 -0
  144. package/skills/service-omni-supervisor-surface-deploy/SKILL.md +85 -0
  145. package/skills/service-omni-supervisor-surface-deploy/references/api-notes.md +47 -0
  146. package/skills/service-omni-supervisor-surface-deploy/scripts/deploy-and-report.sh +216 -0
  147. package/skills/service-omni-supervisor-surface-deploy/scripts/tests/_bootstrap.py +95 -0
  148. package/skills/service-omni-supervisor-surface-deploy/scripts/tests/test_supervisor_surface_contracts.py +154 -0
  149. package/skills/service-omni-supervisor-users-create/SKILL.md +92 -0
  150. package/skills/service-omni-supervisor-users-create/assets/create-supervisors.apex.template +72 -0
  151. package/skills/service-omni-supervisor-users-create/references/apex-patterns.md +73 -0
  152. package/skills/service-omni-supervisor-users-create/references/apex-template-notes.md +41 -0
  153. package/skills/service-omni-supervisor-users-create/scripts/detect-and-create.sh +197 -0
  154. package/skills/service-omni-supervisor-users-create/scripts/detect-existing.sh +112 -0
  155. package/skills/service-omni-supervisor-users-create/scripts/run-create.sh +296 -0
  156. package/skills/service-omni-work-skill-routing-configure/SKILL.md +80 -0
  157. package/skills/service-omni-work-skill-routing-configure/references/api-notes.md +67 -0
  158. package/skills/service-omni-work-skill-routing-configure/scripts/configure-and-report.sh +262 -0
  159. package/skills/service-omni-work-skill-routing-configure/scripts/tests/_bootstrap.py +121 -0
  160. package/skills/service-omni-work-skill-routing-configure/scripts/tests/test_work_skill_routing_contracts.py +161 -0
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: service-omni-presence-user-config-deploy
3
+ description: "Use to deploy an Omni PresenceUserConfig (presence configuration) as one whole-record Metadata write with the decline/ACW fields set consistently: Auto-Accept off, Decline on, Decline Reason on (with a PresenceDeclineReason deployed in the same package), and an After-Conversation-Work wrap-up timer. Agents are assigned by username. Idempotent via the Metadata API files[].state signal. Triggers: create presence configuration, enable decline + decline reason + ACW wrap-up timer for agents, assign presence config to users. Do not use for ServicePresenceStatus (separate skill), for permission-set access to statuses, or to set the channel-level ACW timer on a ServiceChannel."
4
+ allowed-tools: Bash Read Write Edit Glob Grep
5
+ metadata:
6
+ version: "1.0"
7
+ domains: ["Service"]
8
+ minApiVersion: "66.0"
9
+ relatedSkills:
10
+ - "service-omni-agent-users-create"
11
+ - "service-omni-base-settings-configure"
12
+ - "service-omni-channel-setup-coordinate"
13
+ - "service-omni-presence-status-deploy"
14
+ accessCheck:
15
+ - type: license
16
+ value: ServiceCloud
17
+ cliTools:
18
+ - tool: ["jq"]
19
+ semver: ">=1.6"
20
+ - tool: ["python3"]
21
+ semver: ">=3.8"
22
+ - tool: ["sf"]
23
+ semver: ">=2.139.6"
24
+ ---
25
+
26
+ # service-omni-presence-user-config-deploy
27
+
28
+ Deploy an Omni **PresenceUserConfig** (the "presence configuration" that governs how work is offered to agents) together with the **PresenceDeclineReason** it references, in one atomic Metadata API package. The decline / auto-accept / decline-reason / ACW fields have cross-field validators — enabling Decline requires Auto-Accept off, Decline Reason requires Decline on, and the ACW timer must be paired with its max time — so they can only be set correctly as a **single whole-record write**, not field-by-field. This skill encodes that consistent record and assigns it to the given agents. It runs after `service-omni-agent-users-create` / `service-omni-presence-status-deploy` and is invoked by `service-omni-channel-setup-coordinate` as a rep-experience step.
29
+
30
+ ## Inputs
31
+
32
+ ```bash
33
+ bash scripts/deploy-and-report.sh <org-alias> [config_developer_name] [agent_usernames_csv]
34
+ ```
35
+
36
+ - `org-alias` (required).
37
+ - `config_developer_name` (optional, default `Omni_Demo_Presence_Config`).
38
+ - `agent_usernames_csv` (optional). Comma-separated Usernames (`…@…`) and/or 15/18-char User Ids (`005…`) to assign; Ids are resolved to usernames (metadata assigns by username). May also be set via `AGENT_USERNAMES_CSV`. Empty → the config deploys with no user assignments.
39
+
40
+ Env overrides: `DECLINE_REASON_LABEL` (default `Training`), `DECLINE_REASON_DEVELOPER_NAME` (default derived from the label), `CAPACITY` (default `5`, 1–100), `ACW_SECONDS` (default `60`, 10–3600), `PRESENCE_STATUS_ON_DECLINE` (optional ServicePresenceStatus DeveloperName).
41
+
42
+ ## Preconditions and safety
43
+
44
+ - Target org authenticated via `sf` CLI, Service Cloud license, `sf` CLI ≥ 2.139.6.
45
+ - Omni-Channel base settings enabled (`service-omni-base-settings-configure`).
46
+ - Any `PRESENCE_STATUS_ON_DECLINE` must already exist (`service-omni-presence-status-deploy`).
47
+ - The three-way `safe_to_write` production guard applies.
48
+
49
+ ## Run
50
+
51
+ `deploy-and-report.sh` materializes two components into a temp DX project and deploys them in one call:
52
+
53
+ 1. `PresenceDeclineReason:<reason>` — the decline reason (label only).
54
+ 2. `PresenceUserConfig:<config>` — capacity + label, `enableAutoAccept=false`, `enableDecline=true`, `enableDeclineReason=true`, `declineReasons=<reason>`, `hasAfterConvoWorkTimer=true`, `afterConvoWorkMaxTime=<ACW_SECONDS>`, optional `presenceStatusOnDecline`, and `assignments/users` for the resolved agents.
55
+
56
+ Elements are emitted in strict XSD order. Idempotency comes from the Metadata API `files[].state` per component (`Unchanged`→reused, `Changed`→updated, `Created`→created); the deploy runs `--async` and polls to a terminal state.
57
+
58
+ ## Behavior
59
+
60
+ **Whole-record + consistent.** The record is always emitted with the validator-safe combination, so a re-deploy is a clean no-op rather than a field diff that could trip a cross-field rule.
61
+
62
+ **Non-destructive.** Only the named config and decline reason are written; other presence configs and decline reasons on the org are never touched. Assignments are declared for the resolved agents; the skill does not remove users it did not add (a redeploy declares the full assignment set for this config).
63
+
64
+ ## Output contract
65
+
66
+ A single JSON object: `status` ∈ `created` | `updated` | `reused` | `blocked`, `config` (`{developer_name, label, capacity, acw_seconds, state}`), `decline_reason` (`{developer_name, label, state}`), `assigned_usernames`, `deploy_id`, `manual_actions`, `blocking_issue`.
67
+
68
+ ## Limitations
69
+
70
+ - One presence configuration per invocation.
71
+ - Encodes the decline+ACW rep profile from the steel thread; other field combinations require forking the XML template.
72
+ - Does **not** set the channel-level After-Conversation-Work timer on a `ServiceChannel` (a separate concern), deploy `ServicePresenceStatus`, or grant status access via permission set.
73
+
74
+ ## References
75
+
76
+ | File | When to read |
77
+ |---|---|
78
+ | `references/api-notes.md` | PresenceUserConfig cross-field validators, XSD element order, decline-reason packaging, and ACW field pairing |
79
+ | `scripts/tests/_bootstrap.py` | Test bootstrap loaded by the contract suite to locate the skill root and run its shell entry point |
80
+ | `scripts/tests/test_presence_user_config_contracts.py` | Run after changing the deployment script to verify guard, whole-record, membership, and output contracts |
@@ -0,0 +1,60 @@
1
+ # API notes — service-omni-presence-user-config-deploy
2
+
3
+ ## Why one whole-record Metadata write
4
+
5
+ `PresenceUserConfig` has **cross-field validators** that make field-by-field editing fragile — the
6
+ correct combination only validates when the whole record is written together:
7
+
8
+ - `enableAutoAccept` is allowed only when `enableDecline=false`, and vice versa. They are mutually
9
+ exclusive; this skill sets Auto-Accept **off** and Decline **on**.
10
+ - `enableDeclineReason=true` is permitted only when decline reasons are enabled (Decline on) and at
11
+ least one `declineReasons` entry is present.
12
+ - `presenceStatusOnDecline` is available only when `enableDecline=true`.
13
+ - The ACW timer is paired: `hasAfterConvoWorkTimer=true` **requires** `afterConvoWorkMaxTime`
14
+ (10–3600 s). Optional extension uses `hasAcwExtensionEnabled` + `acwExtensionDuration` +
15
+ `maxExtensions` (not set by this skill).
16
+
17
+ Deploying the record as a single Metadata component applies all of these atomically.
18
+
19
+ ## Required fields + XSD element order
20
+
21
+ `capacity` and `label` are required. Metadata API enforces element order; the emitted document
22
+ follows the schema sequence:
23
+
24
+ ```text
25
+ acwExtensionDuration, afterConvoWorkMaxTime, assignments, capacity, declineReasons,
26
+ enableAutoAccept, enableDecline, enableDeclineReason, enableDisconnectSound, enableRequestSound,
27
+ hasAcwExtensionEnabled, hasAfterConvoWorkTimer, interruptibleCapacity, label, maxExtensions,
28
+ presenceStatusOnDecline, presenceStatusOnPushTimeout, userDisplayName
29
+ ```
30
+
31
+ This skill emits: `afterConvoWorkMaxTime → assignments → capacity → declineReasons →
32
+ enableAutoAccept → enableDecline → enableDeclineReason → hasAfterConvoWorkTimer → label →
33
+ [presenceStatusOnDecline]` — all ascending in the sequence above.
34
+
35
+ ## Decline reason packaging
36
+
37
+ `declineReasons` references a `PresenceDeclineReason` by its DeveloperName (fullName). The two
38
+ components are deployed in the **same package** so the reference resolves in one deploy; a
39
+ standalone config referencing a not-yet-deployed reason would fail. `PresenceDeclineReason` itself
40
+ carries only a `label`.
41
+
42
+ ## Assignments
43
+
44
+ `assignments/users/user` takes **usernames**, not User Ids — the skill resolves any `005…` Ids to
45
+ usernames first. A redeploy declares the full assignment set for this config; the skill does not
46
+ remove users it did not add. The `<assignments>` block is omitted entirely when no agents are given
47
+ (a valid, unassigned config).
48
+
49
+ ## Idempotency signal
50
+
51
+ Per-component `files[].state` from the Metadata API is authoritative: `Unchanged` → reused,
52
+ `Changed` → updated, `Created` → created. The deploy runs `--async` and polls
53
+ `sf project deploy report` to a terminal state; a job that never reaches one is reported as blocked
54
+ rather than assumed successful.
55
+
56
+ ## Out of scope: channel-level ACW
57
+
58
+ The steel thread also mentions an After-Conversation-Work timer on the **Voice `ServiceChannel`**.
59
+ That is a different field on a different object (the channel, not the presence config) and is not set
60
+ here — this skill governs the agent-level presence/wrap-up behavior only.
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env bash
2
+ # deploy-and-report.sh - deploy a PresenceUserConfig (+ referenced PresenceDeclineReason) as one atomic Metadata package with the validator-safe decline/ACW rep profile; idempotent. Args, env, exit codes: SKILL.md.
3
+
4
+ set -euo pipefail
5
+
6
+ if [ $# -lt 1 ]; then
7
+ echo '{"error":"Usage: bash deploy-and-report.sh <org-alias> [config_developer_name] [agent_usernames_csv]"}' >&2
8
+ exit 1
9
+ fi
10
+
11
+ ORG="$1"
12
+ CONFIG_DN="${2:-Omni_Demo_Presence_Config}"
13
+ AGENTS_CSV="${3:-${AGENT_USERNAMES_CSV:-}}"
14
+
15
+ DECLINE_REASON_LABEL="${DECLINE_REASON_LABEL:-Training}"
16
+ DECLINE_REASON_DN="${DECLINE_REASON_DEVELOPER_NAME:-$(printf '%s' "$DECLINE_REASON_LABEL" | tr ' ' '_' | tr -cd 'A-Za-z0-9_')}"
17
+ CAPACITY="${CAPACITY:-5}"
18
+ ACW_SECONDS="${ACW_SECONDS:-60}"
19
+ PRESENCE_STATUS_ON_DECLINE="${PRESENCE_STATUS_ON_DECLINE:-}"
20
+ CONFIG_LABEL=$(printf '%s' "$CONFIG_DN" | tr '_' ' ')
21
+
22
+ # ---- validation ----
23
+ if ! [[ "$CONFIG_DN" =~ ^[A-Za-z][A-Za-z0-9_]{0,79}$ ]]; then
24
+ echo "{\"error\":\"Invalid config_developer_name '$CONFIG_DN'. Must start with a letter and contain only letters, digits, underscores (max 80).\"}" >&2
25
+ exit 1
26
+ fi
27
+ if ! [[ "$DECLINE_REASON_DN" =~ ^[A-Za-z][A-Za-z0-9_]{0,79}$ ]]; then
28
+ echo "{\"error\":\"Invalid DECLINE_REASON_DEVELOPER_NAME '$DECLINE_REASON_DN'.\"}" >&2
29
+ exit 1
30
+ fi
31
+ if ! [[ "$CAPACITY" =~ ^[0-9]+$ ]] || [ "$CAPACITY" -lt 1 ] || [ "$CAPACITY" -gt 100 ]; then
32
+ echo "{\"error\":\"Invalid CAPACITY '$CAPACITY' - must be an integer 1..100.\"}" >&2
33
+ exit 1
34
+ fi
35
+ if ! [[ "$ACW_SECONDS" =~ ^[0-9]+$ ]] || [ "$ACW_SECONDS" -lt 10 ] || [ "$ACW_SECONDS" -gt 3600 ]; then
36
+ echo "{\"error\":\"Invalid ACW_SECONDS '$ACW_SECONDS' - must be an integer 10..3600 (Metadata API bound).\"}" >&2
37
+ exit 1
38
+ fi
39
+ if [ -n "$PRESENCE_STATUS_ON_DECLINE" ] && ! [[ "$PRESENCE_STATUS_ON_DECLINE" =~ ^[A-Za-z][A-Za-z0-9_]{0,79}$ ]]; then
40
+ echo "{\"error\":\"Invalid PRESENCE_STATUS_ON_DECLINE '$PRESENCE_STATUS_ON_DECLINE'.\"}" >&2
41
+ exit 1
42
+ fi
43
+
44
+ if ! sf org display --target-org "$ORG" --json >/dev/null 2>&1; then
45
+ echo "{\"error\":\"Org alias '$ORG' is not authenticated. Run: sf org login web -a $ORG -r <my-domain-url>\"}" >&2
46
+ exit 1
47
+ fi
48
+
49
+ # Escape the five XML predefined entities so a label/username with & < > " ' cannot break the
50
+ # hand-built metadata XML (or inject elements) when interpolated into the heredocs below.
51
+ xml_escape() {
52
+ local s="$1"
53
+ s="${s//&/&amp;}"; s="${s//</&lt;}"; s="${s//>/&gt;}"; s="${s//\"/&quot;}"; s="${s//\'/&apos;}"
54
+ printf '%s' "$s"
55
+ }
56
+
57
+ emit_blocked() {
58
+ local msg="$1"; local target_skill="${2:-}"
59
+ local manual_actions='[]'
60
+ if [ -n "$target_skill" ]; then
61
+ manual_actions=$(jq -n --arg ts "$target_skill" '[{id:"MISSING_PREREQ",title:"Run prerequisite skill",target_skill:$ts}]')
62
+ fi
63
+ jq -n --arg msg "$msg" --arg cdn "$CONFIG_DN" --arg rdn "$DECLINE_REASON_DN" --argjson ma "$manual_actions" \
64
+ '{skill:"service-omni-presence-user-config-deploy",status:"blocked",
65
+ config:{developer_name:$cdn,state:"not_deployed"},
66
+ decline_reason:{developer_name:$rdn,state:"not_deployed"},
67
+ assigned_usernames:[],deploy_id:null,manual_actions:$ma,blocking_issue:$msg}'
68
+ exit 1
69
+ }
70
+
71
+ emit_plan() {
72
+ local detail="$1"
73
+ jq -n --arg detail "$detail" --arg cdn "$CONFIG_DN" \
74
+ '{skill:"service-omni-presence-user-config-deploy",status:"action_needed",plan_mode:true,
75
+ plan_detail:$detail,config:{developer_name:$cdn},manual_actions:[],blocking_issue:null}'
76
+ exit 0
77
+ }
78
+
79
+ # Step 1 - Organization + safe_to_write guard
80
+ ORG_JSON=$(sf data query --target-org "$ORG" \
81
+ --query "SELECT IsSandbox, TrialExpirationDate, OrganizationType FROM Organization LIMIT 1" --json 2>/dev/null) \
82
+ || emit_blocked "Failed to query Organization for the safe_to_write guard."
83
+ IS_SANDBOX=$(echo "$ORG_JSON" | jq -r '.result.records[0].IsSandbox')
84
+ TRIAL_EXP=$(echo "$ORG_JSON" | jq -r '.result.records[0].TrialExpirationDate // "null"')
85
+ ORG_TYPE=$(echo "$ORG_JSON" | jq -r '.result.records[0].OrganizationType')
86
+ SAFE_TO_WRITE=false
87
+ if [ "$IS_SANDBOX" = "true" ] || [ "$TRIAL_EXP" != "null" ] \
88
+ || [ "$ORG_TYPE" = "Developer Edition" ] || [ "$ORG_TYPE" = "Base Edition" ]; then
89
+ SAFE_TO_WRITE=true
90
+ fi
91
+ [ "$SAFE_TO_WRITE" = "true" ] || emit_blocked "Refusing to deploy a presence configuration to a real production customer org (IsSandbox=$IS_SANDBOX, TrialExpirationDate=$TRIAL_EXP, OrganizationType=$ORG_TYPE). Target a sandbox, trial CDO, or Developer Edition org."
92
+
93
+ # Step 2 - Resolve agent usernames (accept usernames and/or 005... Ids)
94
+ USERNAMES=()
95
+ if [ -n "$AGENTS_CSV" ]; then
96
+ ID_IN=""; RAW_USERNAMES=()
97
+ while IFS= read -r tok; do
98
+ [ -z "$tok" ] && continue
99
+ if [[ "$tok" =~ ^005[A-Za-z0-9]{12}([A-Za-z0-9]{3})?$ ]]; then
100
+ ID_IN="${ID_IN:+$ID_IN,}'$tok'"
101
+ elif [[ "$tok" =~ ^[^,\'\"[:space:]]+@[^,\'\"[:space:]]+$ ]]; then
102
+ RAW_USERNAMES+=("$tok")
103
+ else
104
+ emit_blocked "agent_usernames_csv token is neither a Username (...@...) nor a 15/18-char User Id (005...): '$tok'"
105
+ fi
106
+ done <<< "$(printf '%s' "$AGENTS_CSV" | tr ',' '\n' | sed 's/^ *//;s/ *$//')"
107
+
108
+ for u in "${RAW_USERNAMES[@]}"; do USERNAMES+=("$u"); done
109
+ if [ -n "$ID_IN" ]; then
110
+ # Fail-closed: a swallowed query failure would silently drop the Id-resolved users and deploy a
111
+ # config bound to fewer agents than requested. Block on both a non-zero query and an unparseable body.
112
+ IDU_JSON=$(sf data query --target-org "$ORG" --json \
113
+ --query "SELECT Username FROM User WHERE Id IN ($ID_IN) AND IsActive=true" 2>/dev/null) \
114
+ || emit_blocked "Could not resolve agent User Ids to usernames (query failed: authentication, network, or API access). Refusing to deploy a presence config bound to an unverified user set."
115
+ echo "$IDU_JSON" | jq -e '.result.records' >/dev/null 2>&1 \
116
+ || emit_blocked "Agent User Id resolution returned no parseable records (inconclusive). Refusing to deploy against an unverified user set."
117
+ while IFS= read -r u; do [ -n "$u" ] && USERNAMES+=("$u"); done \
118
+ < <(echo "$IDU_JSON" | jq -r '.result.records[].Username // empty')
119
+ fi
120
+ fi
121
+
122
+ # PLAN_ONLY - detection is read-only; report intent and stop before any write.
123
+ if [ "${PLAN_ONLY:-}" = "1" ]; then
124
+ emit_plan "Would deploy PresenceDeclineReason '$DECLINE_REASON_DN' and PresenceUserConfig '$CONFIG_DN' (capacity=$CAPACITY, ACW=${ACW_SECONDS}s, decline+decline-reason on, auto-accept off) assigned to ${#USERNAMES[@]} agent(s)."
125
+ fi
126
+
127
+ # Step 3 - Materialize the two components in XSD order
128
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
129
+ WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
130
+ cp "$SCRIPT_DIR/../assets/sfdx-project.json" "$WORK/sfdx-project.json" 2>/dev/null \
131
+ || echo '{"packageDirectories":[{"path":"force-app","default":true}],"sourceApiVersion":"66.0"}' > "$WORK/sfdx-project.json"
132
+ DR_DIR="$WORK/force-app/main/default/presenceDeclineReasons"
133
+ PUC_DIR="$WORK/force-app/main/default/presenceUserConfigs"
134
+ mkdir -p "$DR_DIR" "$PUC_DIR"
135
+
136
+ # PresenceDeclineReason (label only).
137
+ cat > "$DR_DIR/${DECLINE_REASON_DN}.presenceDeclineReason-meta.xml" <<XML
138
+ <?xml version="1.0" encoding="UTF-8"?>
139
+ <PresenceDeclineReason xmlns="http://soap.sforce.com/2006/04/metadata">
140
+ <label>$(xml_escape "$DECLINE_REASON_LABEL")</label>
141
+ </PresenceDeclineReason>
142
+ XML
143
+
144
+ # Build the <assignments> block only when we have usernames.
145
+ ASSIGN_BLOCK=""
146
+ if [ ${#USERNAMES[@]} -gt 0 ]; then
147
+ USER_LINES=""
148
+ for u in "${USERNAMES[@]}"; do USER_LINES+=" <user>$(xml_escape "$u")</user>
149
+ "; done
150
+ ASSIGN_BLOCK=" <assignments>
151
+ <users>
152
+ ${USER_LINES} </users>
153
+ </assignments>
154
+ "
155
+ fi
156
+
157
+ # Optional presenceStatusOnDecline (emitted in XSD order, after label).
158
+ PSOD_LINE=""
159
+ [ -n "$PRESENCE_STATUS_ON_DECLINE" ] && PSOD_LINE=" <presenceStatusOnDecline>${PRESENCE_STATUS_ON_DECLINE}</presenceStatusOnDecline>
160
+ "
161
+
162
+ # PresenceUserConfig - elements in strict XSD order:
163
+ # afterConvoWorkMaxTime, assignments, capacity, declineReasons, enableAutoAccept, enableDecline,
164
+ # enableDeclineReason, hasAfterConvoWorkTimer, label, presenceStatusOnDecline.
165
+ cat > "$PUC_DIR/${CONFIG_DN}.presenceUserConfig-meta.xml" <<XML
166
+ <?xml version="1.0" encoding="UTF-8"?>
167
+ <PresenceUserConfig xmlns="http://soap.sforce.com/2006/04/metadata">
168
+ <afterConvoWorkMaxTime>${ACW_SECONDS}</afterConvoWorkMaxTime>
169
+ ${ASSIGN_BLOCK} <capacity>${CAPACITY}</capacity>
170
+ <declineReasons>${DECLINE_REASON_DN}</declineReasons>
171
+ <enableAutoAccept>false</enableAutoAccept>
172
+ <enableDecline>true</enableDecline>
173
+ <enableDeclineReason>true</enableDeclineReason>
174
+ <hasAfterConvoWorkTimer>true</hasAfterConvoWorkTimer>
175
+ <label>$(xml_escape "$CONFIG_LABEL")</label>
176
+ ${PSOD_LINE}</PresenceUserConfig>
177
+ XML
178
+
179
+ # Step 4 - Deploy both components (async + poll to terminal state)
180
+ START_JSON=$(cd "$WORK" && sf project deploy start --target-org "$ORG" \
181
+ --metadata "PresenceDeclineReason:${DECLINE_REASON_DN}" \
182
+ --metadata "PresenceUserConfig:${CONFIG_DN}" \
183
+ --async --json 2>/dev/null || true)
184
+ DEPLOY_ID=$(echo "$START_JSON" | jq -r '.result.id // ""' 2>/dev/null)
185
+ DEPLOY_JSON="$START_JSON"
186
+
187
+ if [ -n "$DEPLOY_ID" ] && [ "$DEPLOY_ID" != "null" ]; then
188
+ POLL_STATUS=""
189
+ for _ in $(seq 1 60); do
190
+ REPORT_JSON=$(cd "$WORK" && sf project deploy report --job-id "$DEPLOY_ID" --target-org "$ORG" --json 2>/dev/null || true)
191
+ CUR_STATUS=$(echo "$REPORT_JSON" | jq -r '.result.status // ""' 2>/dev/null)
192
+ case "$CUR_STATUS" in
193
+ Succeeded|Failed|SucceededPartial|Canceled) DEPLOY_JSON="$REPORT_JSON"; POLL_STATUS="$CUR_STATUS"; break ;;
194
+ esac
195
+ sleep 15
196
+ done
197
+ [ -n "$POLL_STATUS" ] || emit_blocked "Presence-config deploy job $DEPLOY_ID did not reach a terminal state within the poll budget. Not reporting success."
198
+ fi
199
+
200
+ DEPLOY_SUCCESS=$(echo "$DEPLOY_JSON" | jq -r '.result.success // false' 2>/dev/null)
201
+ DEPLOY_ID=$(echo "$DEPLOY_JSON" | jq -r '.result.id // ""' 2>/dev/null)
202
+
203
+ if [ "$DEPLOY_SUCCESS" != "true" ]; then
204
+ FAILURES=$(echo "$DEPLOY_JSON" | jq -r '.result.details.componentFailures // [] | map(.problem) | join("; ")' 2>/dev/null)
205
+ [ -n "$FAILURES" ] && [ "$FAILURES" != "null" ] || FAILURES=$(echo "$DEPLOY_JSON" | jq -r '.message // .result.errorMessage // "Unknown deploy error"' 2>/dev/null)
206
+ # The presence-status arm must only fire when PRESENCE_STATUS_ON_DECLINE is set; as a case glob an
207
+ # empty value becomes `**`, which matches ANY failure and misattributes every error. Check it
208
+ # explicitly (literal grep -F) after the base-settings arm to preserve the original priority.
209
+ case "$FAILURES" in
210
+ *INVALID_TYPE*|*"OmniChannel"*) emit_blocked "Presence-config deploy failed ($FAILURES). Omni-Channel base settings may not be enabled." "service-omni-base-settings-configure" ;;
211
+ *)
212
+ if [ -n "$PRESENCE_STATUS_ON_DECLINE" ] && printf '%s' "$FAILURES" | grep -qF -- "$PRESENCE_STATUS_ON_DECLINE"; then
213
+ emit_blocked "Presence-config deploy failed: presenceStatusOnDecline '$PRESENCE_STATUS_ON_DECLINE' does not exist. Deploy it first." "service-omni-presence-status-deploy"
214
+ fi
215
+ emit_blocked "Presence-config deploy failed: $FAILURES"
216
+ ;;
217
+ esac
218
+ fi
219
+
220
+ get_state() {
221
+ local fn="$1"; local type="$2"
222
+ echo "$DEPLOY_JSON" | jq -r --arg fn "$fn" --arg t "$type" \
223
+ '.result.files[] | select(.fullName==$fn and .type==$t) | .state' 2>/dev/null | head -1
224
+ }
225
+ STATE_DR=$(get_state "$DECLINE_REASON_DN" "PresenceDeclineReason"); STATE_DR="${STATE_DR:-unknown}"
226
+ STATE_PUC=$(get_state "$CONFIG_DN" "PresenceUserConfig"); STATE_PUC="${STATE_PUC:-unknown}"
227
+
228
+ for pair in "DECLINE_REASON:$STATE_DR" "CONFIG:$STATE_PUC"; do
229
+ st="${pair#*:}"
230
+ case "$st" in
231
+ Created|Changed|Unchanged) ;;
232
+ *) emit_blocked "Deploy reported success overall but component ${pair%%:*} has an unrecognized/absent state \"$st\" (expected Created, Changed, or Unchanged). Not reporting success." ;;
233
+ esac
234
+ done
235
+
236
+ # Skill status: created if any Created; else updated if any Changed; else reused.
237
+ STATUS="reused"
238
+ if [ "$STATE_DR" = "Created" ] || [ "$STATE_PUC" = "Created" ]; then
239
+ STATUS="created"
240
+ elif [ "$STATE_DR" = "Changed" ] || [ "$STATE_PUC" = "Changed" ]; then
241
+ STATUS="updated"
242
+ fi
243
+
244
+ ASSIGNED_JSON=$(printf '%s\n' "${USERNAMES[@]:-}" | jq -R . | jq -s 'map(select(length>0))')
245
+
246
+ jq -n \
247
+ --arg status "$STATUS" \
248
+ --arg cdn "$CONFIG_DN" --arg clbl "$CONFIG_LABEL" --argjson cap "$CAPACITY" --argjson acw "$ACW_SECONDS" --arg cstate "$STATE_PUC" \
249
+ --arg rdn "$DECLINE_REASON_DN" --arg rlbl "$DECLINE_REASON_LABEL" --arg rstate "$STATE_DR" \
250
+ --argjson assigned "$ASSIGNED_JSON" \
251
+ --arg deploy_id "$DEPLOY_ID" \
252
+ '{
253
+ skill: "service-omni-presence-user-config-deploy",
254
+ status: $status,
255
+ config: {developer_name:$cdn, label:$clbl, capacity:$cap, acw_seconds:$acw, state:$cstate},
256
+ decline_reason: {developer_name:$rdn, label:$rlbl, state:$rstate},
257
+ assigned_usernames: $assigned,
258
+ deploy_id: (if $deploy_id=="" then null else $deploy_id end),
259
+ manual_actions: [],
260
+ blocking_issue: null
261
+ }'
@@ -0,0 +1,106 @@
1
+ """Shared paths + fake-`sf` harness for the presence-user-config contract suite.
2
+
3
+ Drives ``deploy-and-report.sh`` via ``subprocess`` and asserts on exit codes + emitted JSON.
4
+ Org-free and network-free. ``jq`` comes from the real PATH.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ SKILLS_ROOT = Path(__file__).resolve().parents[3]
14
+ SKILL_DIR = "service-omni-presence-user-config-deploy"
15
+ SCRIPT = f"{SKILL_DIR}/scripts/deploy-and-report.sh"
16
+
17
+ # PRODUCTION org - safe_to_write must refuse before any deploy.
18
+ _FAKE_SF_PROD = r"""#!/usr/bin/env bash
19
+ sub="${1:-} ${2:-}"; args="$*"
20
+ case "$sub" in
21
+ "org display") echo '{"status":0,"result":{"id":"00Dxx0000000000EAA"}}'; exit 0;;
22
+ "data query")
23
+ if printf '%s' "$args" | grep -q "FROM Organization"; then
24
+ echo '{"status":0,"result":{"records":[{"IsSandbox":false,"TrialExpirationDate":null,"OrganizationType":"Enterprise Edition"}]}}'
25
+ else
26
+ echo '{"status":0,"result":{"records":[],"totalSize":0}}'
27
+ fi
28
+ exit 0;;
29
+ "project deploy") echo '{"status":1,"result":{"success":false},"message":"FAKE_SF_DEPLOY_SHOULD_NOT_BE_REACHED"}'; exit 1;;
30
+ *) echo '{"status":0,"result":{}}'; exit 0;;
31
+ esac
32
+ """
33
+
34
+ # SANDBOX org. Tunables:
35
+ # FAKE_STATE_DR / FAKE_STATE_PUC → Created|Changed|Unchanged (default Created).
36
+ # FAKE_DEPLOY_FAIL=1 → deploy fails with an OmniChannel-shaped problem.
37
+ # FAKE_CONFIG_DN / FAKE_DR_DN → fullNames echoed in the report (default the script defaults).
38
+ _FAKE_SF_SANDBOX = r"""#!/usr/bin/env bash
39
+ sub="${1:-} ${2:-}"; args="$*"
40
+ STATE_DR="${FAKE_STATE_DR:-Created}"
41
+ STATE_PUC="${FAKE_STATE_PUC:-Created}"
42
+ CONFIG_DN="${FAKE_CONFIG_DN:-Omni_Demo_Presence_Config}"
43
+ DR_DN="${FAKE_DR_DN:-Training}"
44
+ if [ -n "${FAKE_SF_CALLLOG:-}" ]; then printf '%s\n' "$args" >> "$FAKE_SF_CALLLOG"; fi
45
+ case "$sub" in
46
+ "org display") echo '{"status":0,"result":{"id":"00Dxx0000000000EAA"}}'; exit 0;;
47
+ "data query")
48
+ if printf '%s' "$args" | grep -q "FROM Organization"; then
49
+ echo '{"status":0,"result":{"records":[{"IsSandbox":true,"TrialExpirationDate":null,"OrganizationType":"Developer Edition"}]}}'
50
+ elif printf '%s' "$args" | grep -q "FROM User"; then
51
+ echo '{"status":0,"result":{"records":[{"Username":"resolved.byid@example.com"}],"totalSize":1}}'
52
+ else
53
+ echo '{"status":0,"result":{"records":[],"totalSize":0}}'
54
+ fi
55
+ exit 0;;
56
+ "project deploy")
57
+ if printf '%s' "$args" | grep -q "report"; then
58
+ if [ "${FAKE_DEPLOY_FAIL:-0}" = "1" ]; then
59
+ PROB="${FAKE_DEPLOY_PROBLEM:-INVALID_TYPE: OmniChannel not enabled}"
60
+ printf '{"status":0,"result":{"success":false,"id":"0AfXX0000000001","status":"Failed","details":{"componentFailures":[{"problem":"%s"}]}}}' "$PROB"
61
+ else
62
+ printf '{"status":0,"result":{"success":true,"id":"0AfXX0000000001","status":"Succeeded","files":[{"fullName":"%s","type":"PresenceDeclineReason","state":"%s"},{"fullName":"%s","type":"PresenceUserConfig","state":"%s"}]}}' "$DR_DN" "$STATE_DR" "$CONFIG_DN" "$STATE_PUC"
63
+ fi
64
+ else
65
+ # async start → return a job id so the script polls report.
66
+ echo '{"status":0,"result":{"id":"0AfXX0000000001","status":"Queued"}}'
67
+ fi
68
+ exit 0;;
69
+ *) echo '{"status":0,"result":{}}'; exit 0;;
70
+ esac
71
+ """
72
+
73
+
74
+ def run(args, path_prefix=None, scrub_sf=False, extra_env=None):
75
+ env = dict(os.environ)
76
+ if scrub_sf:
77
+ kept = [d for d in env.get("PATH", "").split(os.pathsep)
78
+ if d and not os.path.exists(os.path.join(d, "sf"))]
79
+ env["PATH"] = os.pathsep.join(kept)
80
+ if path_prefix:
81
+ env["PATH"] = path_prefix + os.pathsep + env.get("PATH", "")
82
+ if extra_env:
83
+ env.update({k: str(v) for k, v in extra_env.items()})
84
+ proc = subprocess.run(
85
+ ["bash", str(SKILLS_ROOT / SCRIPT), *args],
86
+ capture_output=True, text=True, env=env, cwd=str(SKILLS_ROOT),
87
+ )
88
+ return proc.returncode, (proc.stdout or "") + (proc.stderr or "")
89
+
90
+
91
+ class FakeSf:
92
+ def __init__(self, body=_FAKE_SF_PROD):
93
+ self._body = body
94
+ self._tmp = None
95
+
96
+ def __enter__(self):
97
+ self._tmp = tempfile.mkdtemp(prefix="puc-fake-sf-")
98
+ sf = Path(self._tmp) / "sf"
99
+ sf.write_text(self._body)
100
+ sf.chmod(0o755)
101
+ return self._tmp
102
+
103
+ def __exit__(self, *exc):
104
+ import shutil
105
+ if self._tmp:
106
+ shutil.rmtree(self._tmp, ignore_errors=True)
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Contract tests for service-omni-presence-user-config-deploy/scripts/deploy-and-report.sh."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+ from _bootstrap import ( # noqa: E402
12
+ FakeSf,
13
+ _FAKE_SF_PROD,
14
+ _FAKE_SF_SANDBOX,
15
+ run,
16
+ )
17
+
18
+
19
+ def _last_json(out: str) -> dict:
20
+ dec = json.JSONDecoder()
21
+ obj, i, n = None, 0, len(out)
22
+ while i < n:
23
+ if out[i] in "{[":
24
+ try:
25
+ cand, end = dec.raw_decode(out, i)
26
+ if isinstance(cand, dict):
27
+ obj = cand
28
+ i = end
29
+ continue
30
+ except json.JSONDecodeError:
31
+ pass
32
+ i += 1
33
+ assert obj is not None, f"no JSON object found in output:\n{out}"
34
+ return obj
35
+
36
+
37
+ class InputContractTests(unittest.TestCase):
38
+ def test_missing_args_rejected(self):
39
+ rc, out = run([], scrub_sf=True)
40
+ self.assertEqual(rc, 1, out)
41
+ self.assertIn("Usage", out)
42
+
43
+ def test_injection_shaped_config_name_rejected(self):
44
+ rc, out = run(["org", "Bad Name; DROP"], scrub_sf=True)
45
+ self.assertEqual(rc, 1, out)
46
+ self.assertIn("Invalid config_developer_name", out)
47
+
48
+ def test_capacity_out_of_range_rejected(self):
49
+ rc, out = run(["org", "Omni_Demo_Presence_Config"], scrub_sf=True,
50
+ extra_env={"CAPACITY": "0"})
51
+ self.assertEqual(rc, 1, out)
52
+ self.assertIn("Invalid CAPACITY", out)
53
+
54
+ def test_acw_out_of_range_rejected(self):
55
+ rc, out = run(["org", "Omni_Demo_Presence_Config"], scrub_sf=True,
56
+ extra_env={"ACW_SECONDS": "5"})
57
+ self.assertEqual(rc, 1, out)
58
+ self.assertIn("Invalid ACW_SECONDS", out)
59
+
60
+
61
+ class ProductionGuardTests(unittest.TestCase):
62
+ def test_refuses_production_org(self):
63
+ with FakeSf(_FAKE_SF_PROD) as p:
64
+ rc, out = run(["prodorg"], path_prefix=p)
65
+ self.assertEqual(rc, 1, out)
66
+ v = _last_json(out)
67
+ self.assertEqual(v["status"], "blocked", out)
68
+ self.assertIn("production", v["blocking_issue"].lower())
69
+
70
+
71
+ class DeployTests(unittest.TestCase):
72
+ def test_created_reports_states_and_assignments(self):
73
+ with FakeSf(_FAKE_SF_SANDBOX) as p:
74
+ rc, out = run(["sandbox", "Omni_Demo_Presence_Config", "agent1@example.com,agent2@example.com"],
75
+ path_prefix=p, extra_env={"FAKE_STATE_DR": "Created", "FAKE_STATE_PUC": "Created"})
76
+ self.assertEqual(rc, 0, out)
77
+ v = _last_json(out)
78
+ self.assertEqual(v["status"], "created", out)
79
+ self.assertEqual(v["config"]["state"], "Created", out)
80
+ self.assertEqual(v["decline_reason"]["state"], "Created", out)
81
+ self.assertEqual(v["config"]["acw_seconds"], 60, out)
82
+ self.assertIn("agent1@example.com", v["assigned_usernames"], out)
83
+ self.assertIn("agent2@example.com", v["assigned_usernames"], out)
84
+
85
+ def test_reused_when_both_unchanged(self):
86
+ with FakeSf(_FAKE_SF_SANDBOX) as p:
87
+ rc, out = run(["sandbox"], path_prefix=p,
88
+ extra_env={"FAKE_STATE_DR": "Unchanged", "FAKE_STATE_PUC": "Unchanged"})
89
+ self.assertEqual(rc, 0, out)
90
+ v = _last_json(out)
91
+ self.assertEqual(v["status"], "reused", out)
92
+
93
+ def test_updated_when_config_changed(self):
94
+ with FakeSf(_FAKE_SF_SANDBOX) as p:
95
+ rc, out = run(["sandbox"], path_prefix=p,
96
+ extra_env={"FAKE_STATE_DR": "Unchanged", "FAKE_STATE_PUC": "Changed"})
97
+ self.assertEqual(rc, 0, out)
98
+ v = _last_json(out)
99
+ self.assertEqual(v["status"], "updated", out)
100
+
101
+ def test_deploy_failure_points_at_base_settings(self):
102
+ with FakeSf(_FAKE_SF_SANDBOX) as p:
103
+ rc, out = run(["sandbox"], path_prefix=p, extra_env={"FAKE_DEPLOY_FAIL": "1"})
104
+ self.assertEqual(rc, 1, out)
105
+ v = _last_json(out)
106
+ self.assertEqual(v["status"], "blocked", out)
107
+ self.assertTrue(any(a.get("target_skill") == "service-omni-base-settings-configure"
108
+ for a in v["manual_actions"]), out)
109
+
110
+ def test_empty_status_on_decline_does_not_swallow_unrelated_failure(self):
111
+ # Regression (blocker 3): with PRESENCE_STATUS_ON_DECLINE="" the presence-status case arm was
112
+ # effectively `*""*`, matching EVERY failure and misreporting an unrelated deploy error as a
113
+ # missing presence status. An unrelated failure must now surface as the generic message.
114
+ with FakeSf(_FAKE_SF_SANDBOX) as p:
115
+ rc, out = run(["sandbox"], path_prefix=p, extra_env={
116
+ "FAKE_DEPLOY_FAIL": "1",
117
+ "FAKE_DEPLOY_PROBLEM": "FIELD_CUSTOM_VALIDATION_EXCEPTION: unrelated failure",
118
+ "PRESENCE_STATUS_ON_DECLINE": "",
119
+ })
120
+ self.assertEqual(rc, 1, out)
121
+ v = _last_json(out)
122
+ self.assertEqual(v["status"], "blocked", out)
123
+ self.assertIn("FIELD_CUSTOM_VALIDATION_EXCEPTION", v["blocking_issue"], out)
124
+ self.assertNotIn("presenceStatusOnDecline", v["blocking_issue"], out)
125
+ self.assertFalse(any(a.get("target_skill") == "service-omni-presence-status-deploy"
126
+ for a in v.get("manual_actions", [])), out)
127
+
128
+ def test_plan_only_reports_without_deploying(self):
129
+ with FakeSf(_FAKE_SF_SANDBOX) as p:
130
+ calllog = str(Path(p) / "calls.log")
131
+ rc, out = run(["sandbox", "Omni_Demo_Presence_Config", "agent1@example.com"],
132
+ path_prefix=p, extra_env={"PLAN_ONLY": "1", "FAKE_SF_CALLLOG": calllog})
133
+ log = Path(calllog).read_text() if Path(calllog).exists() else ""
134
+ self.assertEqual(rc, 0, out)
135
+ v = _last_json(out)
136
+ self.assertTrue(v.get("plan_mode"), out)
137
+ self.assertEqual(v["status"], "action_needed", out)
138
+ self.assertNotIn("project deploy", log, "plan mode must not deploy")
139
+
140
+
141
+ if __name__ == "__main__":
142
+ unittest.main(verbosity=2)