diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index bb50773e6..c0786e83b 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -306,7 +306,7 @@ pushed. Run the push pipeline when the maker asks to push local changes. | User intent | Skill to read | |-------------|--------------| | Run common ESS foundation setup (`/setup`) | `src/skills/foundation-setup/SKILL.md` | -| Provision/connect the Workday setup environment (`/connect workday`) | `src/skills/setup/SKILL.md` | +| Provision/connect Workday for the active ESS HR agent (`/connect workday` or `/connect-workday`) | `src/skills/connect/SKILL.md` | | Connect to ServiceNow/Workday | `src/skills/connect/SKILL.md` | | Create a topic | `src/skills/topics/create-eval-driven/SKILL.md` | | Create a workflow | `src/skills/workflows/create/SKILL.md` | diff --git a/solutions/ess-maker-skills/README.md b/solutions/ess-maker-skills/README.md index 82a19e3f1..a223b7860 100644 --- a/solutions/ess-maker-skills/README.md +++ b/solutions/ess-maker-skills/README.md @@ -271,10 +271,11 @@ integration with the ESS IT Agent is not supported in this release. **Verify-first approach:** The kit runs API checks against your Workday tenant before asking you to configure anything. On the legacy path, if ISU accounts, auth policies, permissions, or the RaaS report are already set up (common on shared tenants), those tasks are automatically skipped. -**Test and production deployment:** `/connect workday` is the development -environment experience. After the ESS DA HR agent and Workday package are -deployed to Test or Production, an administrator runs the post-deployment -Dataverse authorization script for that target environment. See +**Test and production deployment:** `/connect workday` uses the same readiness +gates for whichever supported environment is selected. When the ESS DA HR +agent and Workday package are moved through managed ALM, an administrator must +also run the post-deployment Dataverse authorization script in each target +environment. See [`scripts/alm/README.md`](scripts/alm/README.md) for the required parameters, safe preview, execution, and verification procedure. @@ -286,11 +287,11 @@ end-to-end Workday scenario. Settings without a reliable DA-scoped API require explicit maker or administrator confirmation rather than being reported as automatically verified. -At the beginning of the experience, the skill presents the complete setup plan -and identifies when an Entra administrator, Workday administrator, Power -Platform/Dataverse administrator, InfoSec administrator, or Workday test user -is required. This lets the maker arrange the required participants before the -setup reaches a permission-dependent step. +The skill presents seven customer milestones—preflight, Microsoft Entra, +Workday administrator, connections, runtime configuration, conditional network +readiness, and employee validation—while retaining detailed technical checks +internally. It shows the administrator needed for the current milestone rather +than repeating the full technical checklist on every resume. **What you can build after connecting:** - Look up employee information, compensation, service anniversary, cost center diff --git a/solutions/ess-maker-skills/scripts/activate_workday_da_flows.py b/solutions/ess-maker-skills/scripts/activate_workday_da_flows.py new file mode 100644 index 000000000..c340f698d --- /dev/null +++ b/solutions/ess-maker-skills/scripts/activate_workday_da_flows.py @@ -0,0 +1,246 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Preview, activate, and verify the managed Workday runtime cloud flows.""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any, Callable, Mapping + +from auth import authenticate, query_all, update_record +from bind_workday_da_connections import ( + DATAVERSE_LOGICAL_NAME, + WORKDAY_LOGICAL_NAME, + query_runtime_references, +) +from workday_da_contract import load_definition + + +PLAN_MARKER = "WORKDAY_DA_FLOW_ACTIVATION_PLAN_JSON:" +APPLIED_MARKER = "WORKDAY_DA_FLOWS_ACTIVATED_JSON:" +FAILED_MARKER = "WORKDAY_DA_FLOW_ACTIVATION_FAILED_JSON:" +ACTIVE_STATE = 1 +ACTIVE_STATUS = 2 +CLOUD_FLOW_CATEGORY = 5 + + +class WorkdayDAFlowActivationError(RuntimeError): + """Raised when managed Workday flows cannot be activated safely.""" + + +def _flow_names( + package_flavor: str, + *, + definition: Mapping[str, Any], +) -> list[str]: + try: + package = definition["packages"][package_flavor] + except KeyError as exc: + raise WorkdayDAFlowActivationError( + f"Unknown Workday package flavor: {package_flavor}." + ) from exc + names = package.get("flowNames") + if not isinstance(names, list) or not names: + raise WorkdayDAFlowActivationError( + f"Package flavor {package_flavor} has no reviewed flow catalog; " + "activate its flows manually." + ) + return [str(name) for name in names] + + +def _query_target_flows( + environment_url: str, + token: str, + flow_names: list[str], + *, + query: Callable[..., list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + filter_expr = " or ".join( + f"name eq '{name.replace(chr(39), chr(39) * 2)}'" + for name in flow_names + ) + rows = query( + environment_url, + token, + "workflows", + "workflowid,name,statecode,statuscode,category", + filter_expr, + ) + grouped: dict[str, list[dict[str, Any]]] = { + name: [] for name in flow_names + } + for row in rows: + observed = str(row.get("name") or "").casefold() + for expected in grouped: + if observed == expected.casefold(): + grouped[expected].append(row) + invalid = { + name: len(matches) + for name, matches in grouped.items() + if len(matches) != 1 + } + if invalid: + raise WorkdayDAFlowActivationError( + "Expected exactly one installed Workday flow for each reviewed " + f"name; observed {json.dumps(invalid, sort_keys=True)}." + ) + resolved = {name: matches[0] for name, matches in grouped.items()} + non_cloud = [ + name + for name, row in resolved.items() + if row.get("category") != CLOUD_FLOW_CATEGORY + ] + if non_cloud: + raise WorkdayDAFlowActivationError( + "Refusing to activate non-cloud workflow records: " + + ", ".join(sorted(non_cloud)) + ) + return resolved + + +def activate_workday_flows( + environment_url: str, + package_flavor: str, + *, + apply: bool, + preferred_username: str | None = None, + definition: Mapping[str, Any] | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, +) -> dict[str, Any]: + """Preview or activate the reviewed flows for one Workday package.""" + environment_url = environment_url.rstrip("/") + active_definition = definition or load_definition() + flow_names = _flow_names( + package_flavor, + definition=active_definition, + ) + token = token_provider( + environment_url, + preferred_username=preferred_username, + ) + references = query_runtime_references( + environment_url, + token, + query=query, + ) + unbound = [ + logical_name + for logical_name in ( + WORKDAY_LOGICAL_NAME, + DATAVERSE_LOGICAL_NAME, + ) + if not references[logical_name].get("connectionid") + ] + if unbound: + raise WorkdayDAFlowActivationError( + "Workday runtime connection references must be bound before flow " + "activation: " + ", ".join(unbound) + ) + + flows = _query_target_flows( + environment_url, + token, + flow_names, + query=query, + ) + changes = [ + { + "name": name, + "currentState": { + "statecode": flows[name].get("statecode"), + "statuscode": flows[name].get("statuscode"), + }, + "action": ( + "unchanged" + if flows[name].get("statecode") == ACTIVE_STATE + and flows[name].get("statuscode") == ACTIVE_STATUS + else "activate" + ), + } + for name in flow_names + ] + result = { + "environmentUrl": environment_url, + "packageFlavor": package_flavor, + "mode": "apply" if apply else "preview", + "changes": changes, + } + if not apply: + return result + + for change in changes: + if change["action"] == "unchanged": + continue + workflow_id = flows[change["name"]].get("workflowid") + if not workflow_id: + raise WorkdayDAFlowActivationError( + f"{change['name']} has no workflowid." + ) + updater( + environment_url, + token, + "workflows", + workflow_id, + {"statecode": ACTIVE_STATE, "statuscode": ACTIVE_STATUS}, + ) + + verified = _query_target_flows( + environment_url, + token, + flow_names, + query=query, + ) + not_active = [ + name + for name, row in verified.items() + if row.get("statecode") != ACTIVE_STATE + or row.get("statuscode") != ACTIVE_STATUS + ] + if not_active: + raise WorkdayDAFlowActivationError( + "Post-write verification found Workday flows that are not active: " + + ", ".join(sorted(not_active)) + ) + result["verified"] = True + return result + + +def main() -> None: + definition = load_definition() + parser = argparse.ArgumentParser( + description=( + "Preview, activate, and verify the managed Workday runtime flows." + ) + ) + parser.add_argument("--url", required=True) + parser.add_argument( + "--package-flavor", + choices=sorted(definition["packages"]), + required=True, + ) + parser.add_argument("--preferred-username") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + + marker = APPLIED_MARKER if args.apply else PLAN_MARKER + try: + result = activate_workday_flows( + args.url, + args.package_flavor, + apply=args.apply, + preferred_username=args.preferred_username, + definition=definition, + ) + except (OSError, RuntimeError, ValueError) as error: + print(f"{FAILED_MARKER}{json.dumps({'error': str(error)})}") + sys.exit(1) + print(f"{marker}{json.dumps(result, sort_keys=True)}") + + +if __name__ == "__main__": + main() diff --git a/solutions/ess-maker-skills/scripts/bind_workday_da_connections.py b/solutions/ess-maker-skills/scripts/bind_workday_da_connections.py new file mode 100644 index 000000000..cb331fb47 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/bind_workday_da_connections.py @@ -0,0 +1,336 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Bind ESS Workday Runtime solution references to physical connections.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +from typing import Any, Callable + +from auth import authenticate, query_all, update_record +from install_workday_da_extension import ensure_pac_auth, resolve_pac_executable + + +WORKDAY_LOGICAL_NAME = "msdyn_sharedworkdaysoap_workdayruntime" +DATAVERSE_LOGICAL_NAME = ( + "msdyn_sharedcommondataserviceforapps_workdayruntime" +) +WORKDAY_CONNECTOR = "shared_workdaysoap" +DATAVERSE_CONNECTOR = "shared_commondataserviceforapps" +PLAN_MARKER = "WORKDAY_DA_BINDING_PLAN_JSON:" +APPLIED_MARKER = "WORKDAY_DA_BINDING_APPLIED_JSON:" +FAILED_MARKER = "WORKDAY_DA_BINDING_FAILED_JSON:" + + +class WorkdayDABindingError(RuntimeError): + """Raised when connection binding cannot be completed safely.""" + + +def _run(command: list[str], *, timeout: int) -> subprocess.CompletedProcess: + return subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + + +def _connector_name(connection: dict[str, Any]) -> str: + properties = connection.get("properties") or {} + api_id = str(properties.get("apiId") or "") + return api_id.rstrip("/").rsplit("/", 1)[-1].casefold() + + +def _is_connected(connection: dict[str, Any]) -> bool: + properties = connection.get("properties") or {} + statuses = properties.get("statuses") or [] + return any( + str(status.get("status") or "").casefold() == "connected" + for status in statuses + if isinstance(status, dict) + ) + + +def _connection_summary(connection: dict[str, Any]) -> dict[str, str]: + properties = connection.get("properties") or {} + return { + "id": str(connection.get("name") or ""), + "displayName": str(properties.get("displayName") or ""), + "connector": _connector_name(connection), + "status": "Connected" if _is_connected(connection) else "NotConnected", + } + + +def _select_connection( + connections: list[dict[str, Any]], + connector_name: str, + *, + explicit_id: str | None, +) -> dict[str, Any]: + candidates = [ + connection + for connection in connections + if _connector_name(connection) == connector_name.casefold() + and _is_connected(connection) + ] + if explicit_id: + candidates = [ + connection + for connection in candidates + if str(connection.get("name") or "").casefold() + == explicit_id.casefold() + ] + if len(candidates) != 1: + safe_candidates = [ + _connection_summary(connection) for connection in candidates + ] + raise WorkdayDABindingError( + f"Expected exactly one connected {connector_name} connection; " + f"found {len(candidates)}. Candidates: " + f"{json.dumps(safe_candidates, sort_keys=True)}" + ) + return candidates[0] + + +def _parse_connection_inventory(output: str) -> list[dict[str, Any]]: + try: + payload = json.loads(output) + except ValueError as exc: + raise WorkdayDABindingError( + "PAC returned invalid JSON while listing connections." + ) from exc + connections = payload.get("value") + if not isinstance(connections, list): + raise WorkdayDABindingError( + "PAC connection inventory did not contain a value array." + ) + return [item for item in connections if isinstance(item, dict)] + + +def _list_connections( + pac_executable: Path, + environment_url: str, + *, + runner: Callable[..., subprocess.CompletedProcess], +) -> list[dict[str, Any]]: + result = runner( + [ + str(pac_executable), + "connectivity", + "list-connections", + "--environment", + environment_url, + "--json", + ], + timeout=120, + ) + if result.returncode != 0: + raise WorkdayDABindingError( + "PAC could not list the target environment's connections." + ) + return _parse_connection_inventory(result.stdout or "") + + +def query_runtime_references( + environment_url: str, + token: str, + *, + query: Callable[..., list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + filter_expr = ( + f"connectionreferencelogicalname eq '{WORKDAY_LOGICAL_NAME}' or " + f"connectionreferencelogicalname eq '{DATAVERSE_LOGICAL_NAME}'" + ) + rows = query( + environment_url, + token, + "connectionreferences", + "connectionreferenceid,connectionreferencelogicalname," + "connectionreferencedisplayname,connectorid,connectionid,statuscode", + filter_expr, + ) + grouped: dict[str, list[dict[str, Any]]] = { + WORKDAY_LOGICAL_NAME: [], + DATAVERSE_LOGICAL_NAME: [], + } + for row in rows: + logical_name = str( + row.get("connectionreferencelogicalname") or "" + ).casefold() + for expected in grouped: + if logical_name == expected.casefold(): + grouped[expected].append(row) + invalid = { + logical_name: len(matches) + for logical_name, matches in grouped.items() + if len(matches) != 1 + } + if invalid: + raise WorkdayDABindingError( + "Expected exactly one installed runtime connection reference for " + f"each logical name; observed {json.dumps(invalid, sort_keys=True)}." + ) + return { + logical_name: matches[0] + for logical_name, matches in grouped.items() + } + + +def bind_runtime_connections( + environment_url: str, + *, + ring: str, + apply: bool, + preferred_username: str | None = None, + workday_connection_id: str | None = None, + dataverse_connection_id: str | None = None, + pac_resolver: Callable[[], Path] = resolve_pac_executable, + pac_auth: Callable[..., None] = ensure_pac_auth, + runner: Callable[..., subprocess.CompletedProcess] = _run, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, +) -> dict[str, Any]: + """Preview or apply the two ESS Workday Runtime connection bindings.""" + environment_url = environment_url.rstrip("/") + pac_executable = pac_resolver() + pac_auth( + pac_executable, + ring=ring, + environment_url=environment_url, + preferred_username=preferred_username, + ) + connections = _list_connections( + pac_executable, + environment_url, + runner=runner, + ) + workday = _select_connection( + connections, + WORKDAY_CONNECTOR, + explicit_id=workday_connection_id, + ) + dataverse = _select_connection( + connections, + DATAVERSE_CONNECTOR, + explicit_id=dataverse_connection_id, + ) + token = token_provider( + environment_url, + preferred_username=preferred_username, + ) + references = query_runtime_references( + environment_url, + token, + query=query, + ) + targets = { + WORKDAY_LOGICAL_NAME: str(workday.get("name") or ""), + DATAVERSE_LOGICAL_NAME: str(dataverse.get("name") or ""), + } + changes = [ + { + "logicalName": logical_name, + "displayName": str( + references[logical_name].get( + "connectionreferencedisplayname" + ) + or logical_name + ), + "currentConnectionId": references[logical_name].get( + "connectionid" + ), + "targetConnectionId": target_id, + "action": ( + "unchanged" + if str( + references[logical_name].get("connectionid") or "" + ).casefold() + == target_id.casefold() + else "bind" + ), + } + for logical_name, target_id in targets.items() + ] + result = { + "environmentUrl": environment_url, + "mode": "apply" if apply else "preview", + "changes": changes, + } + if not apply: + return result + + for change in changes: + if change["action"] == "unchanged": + continue + reference = references[change["logicalName"]] + record_id = reference.get("connectionreferenceid") + if not record_id: + raise WorkdayDABindingError( + f"{change['logicalName']} has no connectionreferenceid." + ) + updater( + environment_url, + token, + "connectionreferences", + record_id, + {"connectionid": change["targetConnectionId"]}, + ) + + verified = query_runtime_references( + environment_url, + token, + query=query, + ) + for logical_name, target_id in targets.items(): + observed = str(verified[logical_name].get("connectionid") or "") + if observed.casefold() != target_id.casefold(): + raise WorkdayDABindingError( + f"Post-write verification failed for {logical_name}; " + "the expected connection binding did not persist." + ) + result["verified"] = True + return result + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Bind ESS Workday Runtime solution references to connected " + "Workday and Dataverse physical connections." + ) + ) + parser.add_argument("--url", required=True) + parser.add_argument("--ring", choices=["preprod", "prod"], required=True) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--preferred-username") + parser.add_argument("--workday-connection-id") + parser.add_argument("--dataverse-connection-id") + args = parser.parse_args() + + marker = APPLIED_MARKER if args.apply else PLAN_MARKER + try: + result = bind_runtime_connections( + args.url, + ring=args.ring, + apply=args.apply, + preferred_username=args.preferred_username, + workday_connection_id=args.workday_connection_id, + dataverse_connection_id=args.dataverse_connection_id, + ) + except (OSError, RuntimeError, ValueError) as error: + print(f"{FAILED_MARKER}{json.dumps({'error': str(error)})}") + sys.exit(1) + print(f"{marker}{json.dumps(result, sort_keys=True)}") + + +if __name__ == "__main__": + main() diff --git a/solutions/ess-maker-skills/scripts/configure_workday_da_user_context.py b/solutions/ess-maker-skills/scripts/configure_workday_da_user_context.py new file mode 100644 index 000000000..c8f3b7395 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/configure_workday_da_user_context.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Preview, configure, and verify the Workday DA user-context redirect.""" + +from __future__ import annotations + +import argparse +import json +import sys +import uuid +from typing import Any, Callable + +import yaml + +from auth import authenticate, query_all, update_record + + +SETUP_TOPIC_NAME = "[Admin] - User Context - Setup" +SETUP_SCHEMA_SUFFIX = ".topic.setusercontext" +TARGET_TOPIC_NAME = "Workday [System] - 1: Set User Context V2" +TARGET_SCHEMA_SUFFIX = ".topic.workdaysystemgetusercontextv2" +PLAN_MARKER = "WORKDAY_DA_USER_CONTEXT_PLAN_JSON:" +APPLIED_MARKER = "WORKDAY_DA_USER_CONTEXT_APPLIED_JSON:" +FAILED_MARKER = "WORKDAY_DA_USER_CONTEXT_FAILED_JSON:" + + +class WorkdayDAUserContextError(RuntimeError): + """Raised when the user-context redirect cannot be changed safely.""" + + +def _normalized_bot_id(bot_id: str) -> str: + try: + return str(uuid.UUID(str(bot_id))) + except (ValueError, AttributeError) as exc: + raise WorkdayDAUserContextError( + "The active Workday DA agent has an invalid bot ID." + ) from exc + + +def _topic_matches( + topic: dict[str, Any], + *, + display_name: str, + schema_suffix: str, +) -> bool: + name = str(topic.get("name") or "").casefold() + schema = str(topic.get("schemaname") or "").casefold() + return name == display_name.casefold() or schema.endswith(schema_suffix) + + +def _single_topic( + topics: list[dict[str, Any]], + *, + display_name: str, + schema_suffix: str, +) -> dict[str, Any]: + matches = [ + topic + for topic in topics + if _topic_matches( + topic, + display_name=display_name, + schema_suffix=schema_suffix, + ) + ] + if len(matches) != 1: + raise WorkdayDAUserContextError( + f"Expected exactly one '{display_name}' topic in the active agent; " + f"found {len(matches)}." + ) + return matches[0] + + +def _dialog_document(data: str) -> dict[str, Any] | None: + if not data.strip(): + return {} + try: + document = yaml.safe_load(data) + except yaml.YAMLError: + return None + return document if isinstance(document, dict) else None + + +def _begin_dialog(document: dict[str, Any]) -> dict[str, Any]: + begin = document.get("beginDialog") + if isinstance(begin, dict): + return begin + dialog = document.get("dialog") + if isinstance(dialog, dict) and isinstance(dialog.get("beginDialog"), dict): + return dialog["beginDialog"] + return {} + + +def _redirects_exactly(data: str, target_schema: str) -> bool: + document = _dialog_document(data) + if document is None: + return False + actions = _begin_dialog(document).get("actions") + if not isinstance(actions, list) or len(actions) != 1: + return False + action = actions[0] + return ( + isinstance(action, dict) + and str(action.get("kind") or "").casefold() == "begindialog" + and str(action.get("dialog") or "").casefold() + == target_schema.casefold() + ) + + +def _is_bare_scaffold(data: str) -> bool: + document = _dialog_document(data) + if document is None: + return False + if not document: + return True + begin = _begin_dialog(document) + if str(begin.get("kind") or "").casefold() != "onredirect": + return False + actions = begin.get("actions") + return actions in (None, []) + + +def _redirect_yaml(target_schema: str) -> str: + return ( + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: OnRedirect\n" + " id: main\n" + " priority: 0\n" + " actions:\n" + " - kind: BeginDialog\n" + " id: QVk2yi\n" + f" dialog: {target_schema}\n" + ) + + +def inspect_workday_da_user_context( + environment_url: str, + token: str, + bot_id: str, + *, + query: Callable[..., list[dict[str, Any]]] = query_all, +) -> dict[str, Any]: + """Inspect the active agent's setup and Workday V2 target topics.""" + normalized_bot_id = _normalized_bot_id(bot_id) + topics = query( + environment_url.rstrip("/"), + token, + "botcomponents", + "botcomponentid,name,schemaname,data,statecode,statuscode", + ( + f"_parentbotid_value eq '{normalized_bot_id}' " + "and componenttype eq 9" + ), + ) + setup = _single_topic( + topics, + display_name=SETUP_TOPIC_NAME, + schema_suffix=SETUP_SCHEMA_SUFFIX, + ) + target = _single_topic( + topics, + display_name=TARGET_TOPIC_NAME, + schema_suffix=TARGET_SCHEMA_SUFFIX, + ) + target_schema = str(target.get("schemaname") or "") + if not target_schema: + raise WorkdayDAUserContextError( + f"'{TARGET_TOPIC_NAME}' has no schema name." + ) + + setup_data = str(setup.get("data") or "") + if _redirects_exactly(setup_data, target_schema): + action = "unchanged" + elif _is_bare_scaffold(setup_data): + action = "configure" + else: + action = "blocked-custom-content" + + return { + "environmentUrl": environment_url.rstrip("/"), + "botId": normalized_bot_id, + "action": action, + "redirectConfigured": action == "unchanged", + "targetTopicActive": target.get("statecode") == 0, + "setupTopic": { + "id": str(setup.get("botcomponentid") or ""), + "name": str(setup.get("name") or SETUP_TOPIC_NAME), + "schemaName": str(setup.get("schemaname") or ""), + }, + "targetTopic": { + "id": str(target.get("botcomponentid") or ""), + "name": str(target.get("name") or TARGET_TOPIC_NAME), + "schemaName": target_schema, + }, + } + + +def configure_workday_da_user_context( + environment_url: str, + bot_id: str, + *, + apply: bool, + preferred_username: str | None = None, + token_provider: Callable[..., str] = authenticate, + query: Callable[..., list[dict[str, Any]]] = query_all, + updater: Callable[..., bool] = update_record, +) -> dict[str, Any]: + """Preview or apply the Workday V2 user-context redirect.""" + environment_url = environment_url.rstrip("/") + token = token_provider( + environment_url, + preferred_username=preferred_username, + ) + result = inspect_workday_da_user_context( + environment_url, + token, + bot_id, + query=query, + ) + result["mode"] = "apply" if apply else "preview" + + if result["action"] == "blocked-custom-content": + raise WorkdayDAUserContextError( + f"'{SETUP_TOPIC_NAME}' contains custom actions. Refusing to " + "overwrite them automatically; use the Copilot Studio fallback." + ) + if not apply: + return result + + if result["action"] == "configure": + setup_id = result["setupTopic"]["id"] + if not setup_id: + raise WorkdayDAUserContextError( + f"'{SETUP_TOPIC_NAME}' has no botcomponentid." + ) + updater( + environment_url, + token, + "botcomponents", + setup_id, + {"data": _redirect_yaml(result["targetTopic"]["schemaName"])}, + ) + + verified = inspect_workday_da_user_context( + environment_url, + token, + bot_id, + query=query, + ) + if not verified["redirectConfigured"]: + raise WorkdayDAUserContextError( + "Post-write verification did not find the Workday V2 redirect." + ) + result["action"] = ( + "unchanged" if result["action"] == "unchanged" else "configured" + ) + result["redirectConfigured"] = True + result["targetTopicActive"] = verified["targetTopicActive"] + result["verified"] = True + return result + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Preview, configure, and verify the Workday DA user-context " + "redirect." + ) + ) + parser.add_argument("--url", required=True) + parser.add_argument("--bot-id", required=True) + parser.add_argument("--preferred-username") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + + marker = APPLIED_MARKER if args.apply else PLAN_MARKER + try: + result = configure_workday_da_user_context( + args.url, + args.bot_id, + apply=args.apply, + preferred_username=args.preferred_username, + ) + except (OSError, RuntimeError, ValueError) as error: + print(f"{FAILED_MARKER}{json.dumps({'error': str(error)})}") + sys.exit(1) + print(f"{marker}{json.dumps(result, sort_keys=True)}") + + +if __name__ == "__main__": + main() diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/_da_connection_refs.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/_da_connection_refs.py index 3e40167da..e0573bf90 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/_da_connection_refs.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/_da_connection_refs.py @@ -55,6 +55,24 @@ def agent_bot_ids(config: dict[str, Any]) -> list[str]: return ordered +def active_agent_bot_id(config: dict[str, Any]) -> str | None: + """Return the exact active agent bot ID from supported config shapes.""" + active_slug = config.get("activeAgent") + if isinstance(active_slug, str) and active_slug.strip(): + for agent in config.get("agents", []) or []: + if not isinstance(agent, dict) or agent.get("slug") != active_slug: + continue + bot_id = agent.get("botId") + if isinstance(bot_id, str) and bot_id.strip(): + return bot_id.strip() + + single = config.get("agent") or {} + single_bot_id = single.get("botId") if isinstance(single, dict) else None + if isinstance(single_bot_id, str) and single_bot_id.strip(): + return single_bot_id.strip() + return None + + def _bot_connection_references(client, bot_id: str) -> list[dict[str, Any]]: """Fetch + normalize one agent's connection references from the minimalBots components API. @@ -97,9 +115,11 @@ def _bot_connection_references(client, bot_id: str) -> list[dict[str, Any]]: def read_active_agent_connection_references(runner) -> list[dict[str, Any]] | None: - """The single active agent's DA connection references (config - ``agent.botId``), or ``None`` when the AgentBuilder client or the - active-agent botId is unavailable. + """The active agent's DA connection references. + + Resolves either the single-agent ``agent.botId`` shape or + ``activeAgent`` against the multi-agent ``agents`` collection. Returns + ``None`` when the AgentBuilder client or active-agent bot ID is unavailable. Used by ``DV-CONN-001`` (checks/workday_extension.py), which validates the Workday SOAP connection reference on the agent under check. Scoping this to @@ -109,7 +129,7 @@ def read_active_agent_connection_references(runner) -> list[dict[str, Any]] | No """ client = getattr(runner, "agentbuilder", None) config = getattr(runner, "config", None) or {} - agent_id = (config.get("agent") or {}).get("botId") + agent_id = active_agent_bot_id(config) if client is None or not agent_id: return None return _bot_connection_references(client, agent_id) diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py index 13579e985..6a666c573 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -1,38 +1,55 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -""" -ESS FlightCheck — Declarative Agent (DA) Workday Extension Validation. - -Verifies that the Workday extension package for the **Declarative Agent HR** -flavor of Employee Self-Service is installed into the target Power Platform -environment (``setup/workday-da`` skill, step DA1.1). DA and CEA -ship distinct extension packages under distinct schema names (see -``src/reference/solution-catalog.md``); this module never reuses or is -reused by ``checks/workday.py`` / ``checks/workday_extension.py``, which are -CEA-only (they key off Power Automate connection references and -``.mcs.yml`` topics that do not exist in a DA agent). - -Runnable in isolation via ``--checkpoint WD-DA-PKG-001``. +"""ESS FlightCheck — Declarative Agent (DA) Workday validation. + +Validates the DA Workday package, active-agent connection parameter sharing, +and the Workday V2 user-context redirect. DA and CEA ship distinct extension +packages and expose different agent state, so these checks remain separate +from the CEA checks in ``workday.py`` and ``workday_extension.py``. + +The package check runs through the ``workdayda`` scope. Agent wiring checks are +individually runnable through their ``WD-DA-*`` checkpoint IDs. """ from ..runner import CheckResult, Priority, Role, Status from ..agent_scope import validate_agent_slug from auth import query_all, AuthExpiredError # scripts/auth.py, on path via cli.py +from configure_workday_da_user_context import ( + SETUP_TOPIC_NAME, + TARGET_TOPIC_NAME, + WorkdayDAUserContextError, + inspect_workday_da_user_context, +) +from flightcheck.checks._da_connection_refs import ( + WORKDAY_SOAP_CONNECTOR_SUFFIX, + read_active_agent_connection_references, + shared_connection_parameter_values, +) +from workday_da_contract import ( + architecture_for_agent_schema, + assess_package_version, + load_definition, +) -_DA_HR_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedahr" -_DA_IT_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedait" -_DA_HR_WORKDAY_CHILD_SCHEMA = "msdyn_EssDAHRWorkday" -_MOS_WORKDAY_RUNTIME_SCHEMA = "msdyn_EssWorkdayRuntime" -_DA_HR_AGENT_SCHEMAS = { - _DA_HR_PARENT_SCHEMA, - "gptagent_copilotforemployeeselfservicehr", -} -_DA_IT_AGENT_SCHEMAS = { - _DA_IT_PARENT_SCHEMA, - "gptagent_copilotforemployeeselfserviceit", +_WORKDAY_DEFINITION = load_definition() +_ARCHITECTURE_BY_ID = { + architecture["id"]: architecture + for architecture in _WORKDAY_DEFINITION["architectures"] } +_DA_HR_PARENT_SCHEMA = _ARCHITECTURE_BY_ID["classic-da"]["agentSchemaNames"][0] +_NATIVE_DA_HR_SCHEMA = _ARCHITECTURE_BY_ID["native-da"]["agentSchemaNames"][0] +_DA_IT_PARENT_SCHEMA = _WORKDAY_DEFINITION["unsupportedAgentSchemaNames"][0] +_NATIVE_DA_IT_SCHEMA = _WORKDAY_DEFINITION["unsupportedAgentSchemaNames"][1] +_DA_HR_WORKDAY_CHILD_SCHEMA = _WORKDAY_DEFINITION["packages"]["legacy-da"][ + "solutionSchemaName" +] +_MOS_WORKDAY_RUNTIME_SCHEMA = _WORKDAY_DEFINITION["packages"]["runtime"][ + "solutionSchemaName" +] +_DA_HR_AGENT_SCHEMAS = {_DA_HR_PARENT_SCHEMA, _NATIVE_DA_HR_SCHEMA} +_DA_IT_AGENT_SCHEMAS = {_DA_IT_PARENT_SCHEMA, _NATIVE_DA_IT_SCHEMA} _SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" @@ -51,18 +68,236 @@ "employee-self-service/install" ) _DESCRIPTION = "Workday extension package installed for the DA ESS HR agent" +_CONNECTION_DESCRIPTION = "Workday connection parameters shared by the DA agent" +_CONTEXT_DESCRIPTION = "Workday V2 user context configured for the DA agent" def run_workday_da_checks(runner) -> list[CheckResult]: - """Emit the WD-DA-PKG-xxx checkpoints. + """Emit the broad DA Workday scope's package checkpoint.""" + return _check_workday_da_package_installed(runner) - Currently a single check (``WD-DA-PKG-001``); kept as a category - function so additional DA-Workday rows can be added later without - changing the registry / cli wiring. - """ + +def run_workday_da_package_checks(runner) -> list[CheckResult]: + """Emit only the DA Workday package checkpoint.""" return _check_workday_da_package_installed(runner) +def run_workday_da_connection_checks(runner) -> list[CheckResult]: + """Emit only the DA agent parameter-sharing checkpoint.""" + return _check_workday_da_parameter_sharing(runner) + + +def run_workday_da_user_context_checks(runner) -> list[CheckResult]: + """Emit only the DA user-context checkpoint.""" + return _check_workday_da_user_context(runner) + + +def _check_workday_da_parameter_sharing(runner) -> list[CheckResult]: + """WD-DA-CONN-001: active-agent Workday parameters are shared.""" + try: + refs = read_active_agent_connection_references(runner) + except ValueError as exc: + return [_result( + Status.WARNING.value, + f"Agent connection settings could not be interpreted: {exc}", + remediation=( + "Refresh the active agent in Copilot Studio and rerun this " + "check. If the warning remains, validate Connection settings " + "manually before continuing." + ), + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + except Exception as exc: # noqa: BLE001 - surface external API failures + return [_result( + Status.WARNING.value, + "Agent connection settings could not be read: " + f"{type(exc).__name__}: {exc}", + remediation=( + "Confirm access to the active agent, refresh authentication, " + "and rerun this check." + ), + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + + if refs is None: + return [_result( + Status.SKIPPED.value, + "AgentBuilder access or the active agent identity is unavailable.", + remediation=( + "Select the ESS HR agent, sign in to Copilot Studio, and rerun " + "this check." + ), + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + + workday_refs = [ + ref + for ref in refs + if str(ref.get("connectorid") or "").casefold().rstrip("/").endswith( + WORKDAY_SOAP_CONNECTOR_SUFFIX + ) + ] + if not workday_refs: + return [_result( + Status.NOT_CONFIGURED.value, + "The active agent has no Workday connection reference.", + remediation=( + "Open the active agent's Settings > Connection settings page, " + "connect each Workday flow entry, and rerun this check." + ), + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + + unbound = [ref for ref in workday_refs if not ref.get("connectionid")] + if unbound: + return [_result( + Status.NOT_CONFIGURED.value, + f"{len(unbound)} of {len(workday_refs)} Workday connection " + "reference(s) are not connected.", + remediation=( + "Open the active agent's Settings > Connection settings page " + "and connect every Workday flow entry before sharing its " + "parameters." + ), + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + + try: + unshared = [ + ref + for ref in workday_refs + if not shared_connection_parameter_values(ref) + ] + except ValueError as exc: + return [_result( + Status.WARNING.value, + f"Workday shared parameters could not be interpreted: {exc}", + remediation=( + "Open Connection parameters for the Workday connection, save " + "the sharing setting again, then rerun this check." + ), + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + + if unshared: + return [_result( + Status.FAILED.value, + f"{len(unshared)} of {len(workday_refs)} connected Workday " + "reference(s) do not contain shared connection parameters.", + remediation=( + "In the active agent, open Settings > Connection settings > " + "the Workday connection > See details > Connection parameters. " + "Turn on 'Allow permission to share parameters', save, and " + "rerun this check." + ), + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + + return [_result( + Status.PASSED.value, + f"All {len(workday_refs)} connected Workday reference(s) in the " + "active agent contain shared connection parameters.", + checkpoint_id="WD-DA-CONN-001", + description=_CONNECTION_DESCRIPTION, + )] + + +def _check_workday_da_user_context(runner) -> list[CheckResult]: + """WD-DA-CTX-001: setup redirects to an enabled Workday V2 topic.""" + env_url = getattr(runner, "env_url", None) + token = getattr(runner, "dv_token", None) + selected = _selected_agent(runner) + bot_id = selected[1].get("botId") if selected else None + if not env_url or not token or not bot_id: + return [_result( + Status.SKIPPED.value, + "Dataverse access or the active agent bot ID is unavailable.", + remediation=( + "Select the ESS HR agent, refresh Dataverse authentication, " + "and rerun this check." + ), + checkpoint_id="WD-DA-CTX-001", + description=_CONTEXT_DESCRIPTION, + )] + + try: + inspection = inspect_workday_da_user_context( + env_url, + token, + str(bot_id), + ) + except WorkdayDAUserContextError as exc: + return [_result( + Status.FAILED.value, + f"Workday V2 user-context topics could not be resolved: {exc}", + remediation=( + "Open the active ESS HR agent's Topics page and verify that " + f"both '{SETUP_TOPIC_NAME}' and '{TARGET_TOPIC_NAME}' exist " + "exactly once." + ), + checkpoint_id="WD-DA-CTX-001", + description=_CONTEXT_DESCRIPTION, + )] + except AuthExpiredError as exc: + return [_result( + Status.WARNING.value, + str(exc), + remediation="Refresh Dataverse authentication and rerun this check.", + checkpoint_id="WD-DA-CTX-001", + description=_CONTEXT_DESCRIPTION, + )] + except Exception as exc: # noqa: BLE001 - surface external API failures + return [_result( + Status.WARNING.value, + "Workday V2 user context could not be read: " + f"{type(exc).__name__}: {exc}", + remediation=( + "Verify Dataverse read access to the active agent's topics and " + "rerun this check." + ), + checkpoint_id="WD-DA-CTX-001", + description=_CONTEXT_DESCRIPTION, + )] + + if not inspection["redirectConfigured"]: + return [_result( + Status.FAILED.value, + f"'{SETUP_TOPIC_NAME}' does not redirect exclusively to " + f"'{TARGET_TOPIC_NAME}'.", + remediation=( + "Open the setup topic, select its topic reference, choose " + f"'Select a topic', select '{TARGET_TOPIC_NAME}', and save." + ), + checkpoint_id="WD-DA-CTX-001", + description=_CONTEXT_DESCRIPTION, + )] + if not inspection["targetTopicActive"]: + return [_result( + Status.FAILED.value, + f"'{TARGET_TOPIC_NAME}' exists and is selected, but it is disabled.", + remediation=( + "Enable the Workday V2 user-context topic in Copilot Studio, " + "save the agent, and rerun this check." + ), + checkpoint_id="WD-DA-CTX-001", + description=_CONTEXT_DESCRIPTION, + )] + return [_result( + Status.PASSED.value, + f"'{SETUP_TOPIC_NAME}' redirects to the enabled " + f"'{TARGET_TOPIC_NAME}'.", + checkpoint_id="WD-DA-CTX-001", + description=_CONTEXT_DESCRIPTION, + )] + + def _check_workday_da_package_installed(runner) -> list[CheckResult]: """WD-DA-PKG-001: the DA Workday extension package is installed. @@ -74,10 +309,29 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: env_url = getattr(runner, "env_url", None) token = getattr(runner, "dv_token", None) - if not env_url or not token: + if not env_url: + return [_result( + Status.SKIPPED.value, + "The selected Power Platform environment does not expose a " + "Dataverse environment URL.", + remediation=( + "Confirm that the selected environment has a Dataverse " + "database. In Power Platform admin center, select the " + "environment and choose Add Dataverse or Add database. Wait " + "for provisioning to finish, refresh the environment list, " + "and rerun this checkpoint. Ask a Power Platform " + "administrator if the action is unavailable." + ), + )] + + if not token: return [_result( Status.SKIPPED.value, - "Dataverse URL or access token not available in this run.", + "A Dataverse access token is not available in this run.", + remediation=( + "Refresh Dataverse authentication for the selected " + "environment and rerun this checkpoint." + ), )] selected = _selected_agent(runner) @@ -151,11 +405,14 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: s.get("uniquename", "").casefold(): s for s in all_solutions } - required_schema = ( - _MOS_WORKDAY_RUNTIME_SCHEMA - if selected_schema == "gptagent_copilotforemployeeselfservicehr" - else _DA_HR_WORKDAY_CHILD_SCHEMA + architecture = architecture_for_agent_schema( + selected_schema, + definition=_WORKDAY_DEFINITION, ) + package_flavor = architecture["packageFlavor"] + required_schema = _WORKDAY_DEFINITION["packages"][package_flavor][ + "solutionSchemaName" + ] child = installed_names.get(required_schema.casefold()) if not child: return [_result( @@ -168,6 +425,32 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: ), )] + assessment = assess_package_version( + package_flavor, + child.get("version"), + definition=_WORKDAY_DEFINITION, + ) + if assessment.outcome == "invalid": + return [_result( + Status.FAILED.value, + f"The Workday package required by the ESS HR agent is installed, " + f"but its version cannot be validated: {assessment.message}", + remediation=( + "Repair or upgrade the Workday package, confirm Dataverse " + "reports a four-part numeric solution version, and rerun this " + "checkpoint." + ), + )] + if assessment.outcome == "unsupported": + return [_result( + Status.FAILED.value, + assessment.message, + remediation=( + "Upgrade or repair the Workday package to a version supported " + "by this kit, then rerun this checkpoint." + ), + )] + return [_result( Status.PASSED.value, f"Selected ESS HR agent '{selected_slug}' detected. Workday package " @@ -209,14 +492,25 @@ def _selected_agent(runner) -> tuple[str, dict] | None: return None -def _result(status: str, result: str, remediation: str = "") -> CheckResult: +def _result( + status: str, + result: str, + remediation: str = "", + *, + checkpoint_id: str = "WD-DA-PKG-001", + description: str = _DESCRIPTION, +) -> CheckResult: return CheckResult( roles=[Role.ESS_MAKER.value], - checkpoint_id="WD-DA-PKG-001", + checkpoint_id=checkpoint_id, category="Workday DA", - priority=Priority.CRITICAL.value, + priority=( + Priority.CRITICAL.value + if checkpoint_id == "WD-DA-PKG-001" + else Priority.HIGH.value + ), status=status, - description=_DESCRIPTION, + description=description, result=result, remediation=remediation, doc_link=_DOC_LINK, diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 8f7bccf53..5c396aac6 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -863,6 +863,7 @@ def _run_single_checkpoint(args): sys.exit(1) quiet_auth = getattr(args, "quiet_auth", False) + preferred_username = getattr(args, "preferred_username", None) if not quiet_auth: print() print("=" * 64) @@ -924,7 +925,10 @@ def _run_single_checkpoint(args): print("Authenticating to Microsoft Graph...") graph = GraphClient(tenant_id) try: - graph.authenticate() + if preferred_username: + graph.authenticate(preferred_username=preferred_username) + else: + graph.authenticate() if not quiet_auth: print(" Graph: OK") except Exception as e: @@ -936,7 +940,9 @@ def _run_single_checkpoint(args): print("Authenticating to Power Platform Admin API...") pp_admin = PPAdminClient(tenant_id) try: - pp_admin.authenticate() + pp_admin.authenticate( + include_flow=getattr(plan, "requires_flow_token", False) + ) if not quiet_auth: print(" Power Platform: OK") except Exception as e: @@ -1238,6 +1244,15 @@ def main(): "Defaults to activeAgent (or agent.slug) from .local/config.json." ), ) + parser.add_argument( + "--preferred-username", + default=None, + help=( + "Prefer this exact account for interactive and cached " + "authentication. Connect/setup skills use this to avoid repeated " + "account selection in multi-account workspaces." + ), + ) parser.add_argument( "--list-checkpoints", action="store_true", help="List the registered setup checkpoint IDs and families (no broad " diff --git a/solutions/ess-maker-skills/scripts/flightcheck/graph_client.py b/solutions/ess-maker-skills/scripts/flightcheck/graph_client.py index 44ed97df2..2af5300e2 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/graph_client.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/graph_client.py @@ -289,7 +289,7 @@ def __init__(self, tenant_id: str): self.tenant_id = tenant_id self._token: str | None = None - def authenticate(self) -> str: + def authenticate(self, preferred_username: str | None = None) -> str: """Acquire a Graph access token, reusing the shared MSAL cache.""" authority = f"https://login.microsoftonline.com/{self.tenant_id}" cache = msal.SerializableTokenCache() @@ -305,14 +305,32 @@ def authenticate(self) -> str: # Try silent first accounts = app.get_accounts() + preferred = str(preferred_username or "").casefold() + selected_account = next( + ( + account + for account in accounts + if str(account.get("username") or "").casefold() == preferred + ), + accounts[0] if accounts and not preferred else None, + ) result = None - if accounts: - result = app.acquire_token_silent(GRAPH_SCOPES, account=accounts[0]) + if selected_account: + result = app.acquire_token_silent( + GRAPH_SCOPES, + account=selected_account, + ) if not result or "access_token" not in result: print("Opening browser for Microsoft Graph sign-in...") + interactive_options = ( + {"login_hint": preferred_username} + if preferred_username + else {"prompt": "select_account"} + ) result = app.acquire_token_interactive( - GRAPH_SCOPES, prompt="select_account" + GRAPH_SCOPES, + **interactive_options, ) if "access_token" not in result: diff --git a/solutions/ess-maker-skills/scripts/flightcheck/registry.py b/solutions/ess-maker-skills/scripts/flightcheck/registry.py index dc759a9a1..b80e57332 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/registry.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/registry.py @@ -53,7 +53,11 @@ from flightcheck.checks.external_systems import run_external_systems_checks from flightcheck.checks.solution import run_solution_checks from flightcheck.checks.workday import run_workday_checks -from flightcheck.checks.workday_da import run_workday_da_checks +from flightcheck.checks.workday_da import ( + run_workday_da_connection_checks, + run_workday_da_package_checks, + run_workday_da_user_context_checks, +) from flightcheck.checks.workday_tenant import run_workday_tenant_checks from flightcheck.checks.workday_extension import run_workday_extension_checks from flightcheck.checks.topics import run_topic_checks @@ -133,6 +137,7 @@ class CheckpointSpec: clients: frozenset = frozenset() requires_config: bool = True requires_dataverse_endpoint: bool = False + requires_flow_token: bool = False prereqs: tuple = () priority: str = Priority.HIGH.value roles: tuple = () @@ -154,6 +159,7 @@ class ResolvedPlan: clients: frozenset requires_config: bool requires_dataverse_endpoint: bool + requires_flow_token: bool # (category_label, category_fn) pairs to register on the runner, ordered # by CATEGORY_ORDER (prerequisites' functions first), de-duped by function. ordered_fns: list = field(default_factory=list) @@ -272,19 +278,40 @@ class ResolvedPlan: # WD-DA-PKG-001: the Workday extension package for the Declarative Agent # (DA) flavor of ESS is installed in the target env. Queries the # Dataverse `solutions` table for the DA parent (HR/IT) plus its Workday - # child package. Fully independent of ESS-SOLN-001 / WD-PKG-001, which - # only recognize the CEA solution family. + # child package. A successful Dataverse query already proves the database + # is available, so this check deliberately does not pull ENV-002 and its + # separate Power Platform Admin authentication. Fully independent of + # ESS-SOLN-001 / WD-PKG-001, which only recognize the CEA solution family. CheckpointSpec( key="WD-DA-PKG-001", - category_fn=run_workday_da_checks, + category_fn=run_workday_da_package_checks, category_label="Workday DA", clients=frozenset({DATAVERSE}), requires_config=True, requires_dataverse_endpoint=True, - prereqs=("ENV-002",), priority=Priority.CRITICAL.value, roles=(Role.ESS_MAKER.value,), ), + CheckpointSpec( + key="WD-DA-CONN-001", + category_fn=run_workday_da_connection_checks, + category_label="Workday DA", + clients=frozenset({AGENTBUILDER}), + requires_config=True, + requires_dataverse_endpoint=False, + priority=Priority.HIGH.value, + roles=(Role.ESS_MAKER.value,), + ), + CheckpointSpec( + key="WD-DA-CTX-001", + category_fn=run_workday_da_user_context_checks, + category_label="Workday DA", + clients=frozenset({DATAVERSE}), + requires_config=True, + requires_dataverse_endpoint=True, + priority=Priority.HIGH.value, + roles=(Role.ESS_MAKER.value,), + ), # ---- External Systems: WD-001 (prereq-only, hidden from listing) ---- # Sets runner._workday_flows, which the below-early-return Workday checks # (WD-CONN-012, WD-FLOW-*, WD-WF-*, WD-ENV-*, WD-CONN-*) depend on. @@ -295,6 +322,7 @@ class ResolvedPlan: clients=frozenset({PP_ADMIN}), requires_config=True, requires_dataverse_endpoint=True, + requires_flow_token=True, priority=Priority.HIGH.value, roles=(Role.POWER_PLATFORM_ADMIN.value,), listable=False, @@ -536,6 +564,7 @@ class ResolvedPlan: clients=frozenset({PP_ADMIN}), requires_config=True, requires_dataverse_endpoint=True, + requires_flow_token=True, prereqs=("WD-PKG-001", "WD-001"), priority=Priority.HIGH.value, roles=(Role.ESS_MAKER.value,), @@ -764,12 +793,16 @@ def transitive_requirements(checkpoint_id: str) -> ResolvedPlan: clients: frozenset = frozenset() requires_config = False requires_dataverse_endpoint = False + requires_flow_token = False for spec in closure: clients = clients | spec.clients requires_config = requires_config or spec.requires_config requires_dataverse_endpoint = ( requires_dataverse_endpoint or spec.requires_dataverse_endpoint ) + requires_flow_token = ( + requires_flow_token or spec.requires_flow_token + ) # De-dupe category functions, then order by CATEGORY_ORDER. Multiple specs # frequently share a function (the whole Workday block is run_workday_checks), @@ -796,6 +829,7 @@ def _order_index(label: str) -> int: clients=clients, requires_config=requires_config, requires_dataverse_endpoint=requires_dataverse_endpoint, + requires_flow_token=requires_flow_token, ordered_fns=unique, ) diff --git a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py index c338aedd3..ef7d0e092 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -14,21 +14,16 @@ import subprocess import sys -from flightcheck.checks.workday_da import ( - _DA_HR_WORKDAY_CHILD_SCHEMA, - _MOS_WORKDAY_RUNTIME_SCHEMA, -) +from workday_da_contract import load_definition +_WORKDAY_DEFINITION = load_definition() WORKDAY_PACKAGES = { - "runtime": { - "applicationName": _MOS_WORKDAY_RUNTIME_SCHEMA, - "schemaName": _MOS_WORKDAY_RUNTIME_SCHEMA, - }, - "legacy-da": { - "applicationName": "msdyn_EssDAHRWorkdayHCM", - "schemaName": _DA_HR_WORKDAY_CHILD_SCHEMA, - }, + flavor: { + "applicationName": package["applicationName"], + "schemaName": package["solutionSchemaName"], + } + for flavor, package in _WORKDAY_DEFINITION["packages"].items() } CLOUD_FOR_RING = { "preprod": "Preprod", @@ -101,10 +96,19 @@ def _parse_profiles(output: str) -> list[dict]: None, ) if cloud: + username = next( + ( + token + for token in re.split(r"\s+", remainder) + if "@" in token and not token.casefold().startswith("http") + ), + None, + ) profiles.append( { "index": match.group(1), "active": bool(match.group(2)), + "username": username, "cloud": cloud, "environment_url": environment_url, } @@ -117,10 +121,28 @@ def ensure_pac_auth( *, ring: str, environment_url: str, + preferred_username: str | None = None, runner=_run, ) -> None: """Select or create a PAC profile for the requested Power Platform ring.""" cloud = CLOUD_FOR_RING[ring] + normalized_environment = environment_url.rstrip("/").casefold() + normalized_username = ( + preferred_username.casefold() if preferred_username else None + ) + + def matches_target(profile: dict) -> bool: + if profile["cloud"].casefold() != cloud.casefold(): + return False + if ring == "preprod": + if (profile["environment_url"] or "").casefold() != ( + normalized_environment + ): + return False + if normalized_username: + return (profile["username"] or "").casefold() == normalized_username + return True + listed = runner( [pac_executable, "auth", "list"], capture_output=True, @@ -131,21 +153,7 @@ def ensure_pac_auth( if listed.returncode == 0 else [] ) - cloud_matching = [ - profile - for profile in profiles - if profile["cloud"].casefold() == cloud.casefold() - ] - if ring == "preprod": - normalized_environment = environment_url.rstrip("/").casefold() - matching = [ - profile - for profile in cloud_matching - if (profile["environment_url"] or "").casefold() - == normalized_environment - ] - else: - matching = cloud_matching + matching = [profile for profile in profiles if matches_target(profile)] active = [profile for profile in matching if profile["active"]] if len(active) == 1: return @@ -189,6 +197,27 @@ def ensure_pac_auth( raise PacCliError( f"PAC authentication for {cloud} did not complete successfully." ) + if preferred_username: + verified = runner( + [pac_executable, "auth", "list"], + capture_output=True, + timeout=60, + ) + verified_profiles = ( + _parse_profiles(verified.stdout or "") + if verified.returncode == 0 + else [] + ) + verified_active = [ + profile + for profile in verified_profiles + if profile["active"] and matches_target(profile) + ] + if len(verified_active) != 1: + raise PacCliError( + "PAC authentication completed, but the active profile does " + "not match the requested environment and maker account." + ) def install_workday_package( diff --git a/solutions/ess-maker-skills/scripts/requirements.txt b/solutions/ess-maker-skills/scripts/requirements.txt index a60d2a3b9..62dfd7228 100644 --- a/solutions/ess-maker-skills/scripts/requirements.txt +++ b/solutions/ess-maker-skills/scripts/requirements.txt @@ -15,6 +15,12 @@ mcp>=1.29.0,<2.0.0 # YAML parse for pre-push schema validation PyYAML>=6.0.2,<7.0 +# JSON Schema validation for versioned provider setup contracts. +jsonschema>=4.23.0,<5.0 + +# Cross-platform state-file locking for concurrent setup sessions. +portalocker>=2.10.0,<4.0 + # Safe XML parsing for Workday SOAP responses (XXE / billion-laughs hardened). defusedxml>=0.7.1,<0.8 diff --git a/solutions/ess-maker-skills/scripts/workday_da_contract.py b/solutions/ess-maker-skills/scripts/workday_da_contract.py new file mode 100644 index 000000000..43b316b9f --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_da_contract.py @@ -0,0 +1,386 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Load and validate the versioned Workday DA setup contract.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import json +from pathlib import Path +import re +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker + + +SOLUTION_ROOT = Path(__file__).resolve().parent.parent +DEFINITION_ROOT = ( + SOLUTION_ROOT / "src" / "skills" / "setup" / "workday-da" +) +DEFAULT_DEFINITION_PATH = DEFINITION_ROOT / "workday-da.definition.json" +DEFAULT_DEFINITION_SCHEMA_PATH = ( + DEFINITION_ROOT / "workday-da.definition.schema.json" +) +DEFAULT_STATE_SCHEMA_PATH = DEFINITION_ROOT / "workday-da.state.schema.json" +_SOLUTION_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$") + + +class WorkdayDAContractError(ValueError): + """Raised when a Workday DA definition or state document is invalid.""" + + +@dataclass(frozen=True) +class PackageVersionAssessment: + """Result of evaluating one installed solution version.""" + + outcome: str + installed_version: str | None + minimum_inclusive: str | None + maximum_exclusive: str | None + message: str + + +def _load_json(path: Path) -> dict[str, Any]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise WorkdayDAContractError( + f"Workday DA contract file does not exist: {path}" + ) from exc + except json.JSONDecodeError as exc: + raise WorkdayDAContractError( + f"Workday DA contract file is not valid JSON: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayDAContractError( + f"Workday DA contract file must contain a JSON object: {path}" + ) + return document + + +def _validate_schema( + document: Mapping[str, Any], + schema_path: Path, + *, + label: str, +) -> None: + schema = _load_json(schema_path) + try: + Draft202012Validator.check_schema(schema) + except Exception as exc: + raise WorkdayDAContractError( + f"Invalid Workday DA {label} schema: {exc}" + ) from exc + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + errors = sorted( + validator.iter_errors(document), + key=lambda error: tuple(str(part) for part in error.absolute_path), + ) + if not errors: + return + error = errors[0] + location = ".".join(str(part) for part in error.absolute_path) or "" + raise WorkdayDAContractError( + f"Invalid Workday DA {label} at {location}: {error.message}" + ) + + +def _validate_definition_semantics(document: Mapping[str, Any]) -> None: + packages = document["packages"] + for flavor, package in packages.items(): + policy = package["versionPolicy"] + minimum = policy["minimumInclusive"] + maximum = policy["maximumExclusive"] + if policy["status"] == "enforced" and minimum is None: + raise WorkdayDAContractError( + f"Enforced package policy {flavor} must define " + "minimumInclusive" + ) + if minimum is not None and maximum is not None: + if parse_solution_version(minimum) >= parse_solution_version(maximum): + raise WorkdayDAContractError( + f"Package policy {flavor} minimumInclusive must be lower " + "than maximumExclusive" + ) + architecture_ids: set[str] = set() + supported_agent_schemas: set[str] = set() + for architecture in document["architectures"]: + architecture_id = architecture["id"] + if architecture_id in architecture_ids: + raise WorkdayDAContractError( + f"Duplicate Workday DA architecture id: {architecture_id}" + ) + architecture_ids.add(architecture_id) + package_flavor = architecture["packageFlavor"] + if package_flavor not in packages: + raise WorkdayDAContractError( + f"Architecture {architecture_id} references unknown package " + f"flavor {package_flavor}" + ) + for schema_name in architecture["agentSchemaNames"]: + normalized = schema_name.casefold() + if normalized in supported_agent_schemas: + raise WorkdayDAContractError( + f"Agent schema is assigned to multiple architectures: " + f"{schema_name}" + ) + supported_agent_schemas.add(normalized) + + unsupported = { + schema_name.casefold() + for schema_name in document["unsupportedAgentSchemaNames"] + } + overlap = supported_agent_schemas & unsupported + if overlap: + raise WorkdayDAContractError( + "Agent schemas cannot be both supported and unsupported: " + + ", ".join(sorted(overlap)) + ) + + steps = document["steps"] + step_by_id: dict[str, Mapping[str, Any]] = {} + for step in steps: + step_id = step["id"] + if step_id in step_by_id: + raise WorkdayDAContractError( + f"Duplicate Workday DA step id: {step_id}" + ) + if step["owner"] != f"da-{step['phase']}": + raise WorkdayDAContractError( + f"Step {step_id} owner {step['owner']} does not match phase " + f"{step['phase']}" + ) + step_by_id[step_id] = step + + for step_id, step in step_by_id.items(): + for dependency in step["dependsOn"]: + if dependency not in step_by_id: + raise WorkdayDAContractError( + f"Step {step_id} references unknown dependency {dependency}" + ) + if dependency == step_id: + raise WorkdayDAContractError( + f"Step {step_id} cannot depend on itself" + ) + + visiting: set[str] = set() + visited: set[str] = set() + + def visit(step_id: str) -> None: + if step_id in visiting: + raise WorkdayDAContractError( + f"Workday DA step dependencies contain a cycle at {step_id}" + ) + if step_id in visited: + return + visiting.add(step_id) + for dependency in step_by_id[step_id]["dependsOn"]: + visit(dependency) + visiting.remove(step_id) + visited.add(step_id) + + for step_id in step_by_id: + visit(step_id) + + milestone_ids: set[str] = set() + milestone_step_ids: set[str] = set() + for milestone in document["customerMilestones"]: + milestone_id = milestone["id"] + if milestone_id in milestone_ids: + raise WorkdayDAContractError( + f"Duplicate Workday DA customer milestone id: {milestone_id}" + ) + milestone_ids.add(milestone_id) + for step_id in milestone["stepIds"]: + if step_id not in step_by_id: + raise WorkdayDAContractError( + f"Customer milestone {milestone_id} references unknown " + f"step {step_id}" + ) + if step_id in milestone_step_ids: + raise WorkdayDAContractError( + f"Workday DA step {step_id} is assigned to multiple " + "customer milestones" + ) + milestone_step_ids.add(step_id) + ungrouped_step_ids = set(step_by_id) - milestone_step_ids + if ungrouped_step_ids: + raise WorkdayDAContractError( + "Workday DA customer milestones do not cover steps: " + + ", ".join(sorted(ungrouped_step_ids)) + ) + + completion = document["completion"] + final_step_id = completion["finalStepId"] + if final_step_id not in step_by_id: + raise WorkdayDAContractError( + f"Completion references unknown final step {final_step_id}" + ) + required_step_ids = set(completion["requiredStepIds"]) + missing_required = required_step_ids - step_by_id.keys() + if missing_required: + raise WorkdayDAContractError( + "Completion references unknown required steps: " + + ", ".join(sorted(missing_required)) + ) + if final_step_id not in required_step_ids: + raise WorkdayDAContractError( + f"Final step {final_step_id} must be required for completion" + ) + + +def validate_definition( + document: Mapping[str, Any], + *, + schema_path: Path = DEFAULT_DEFINITION_SCHEMA_PATH, +) -> None: + """Validate one Workday DA definition against schema and invariants.""" + _validate_schema(document, schema_path, label="definition") + _validate_definition_semantics(document) + + +def load_definition( + path: Path = DEFAULT_DEFINITION_PATH, + *, + schema_path: Path = DEFAULT_DEFINITION_SCHEMA_PATH, +) -> dict[str, Any]: + """Load and validate the Workday DA definition.""" + document = _load_json(path) + validate_definition(document, schema_path=schema_path) + return document + + +def validate_state( + document: Mapping[str, Any], + *, + schema_path: Path = DEFAULT_STATE_SCHEMA_PATH, +) -> None: + """Validate one migrated Workday DA persisted-state document.""" + _validate_schema(document, schema_path, label="state") + + +def parse_solution_version(value: str) -> tuple[int, int, int, int]: + """Parse the four-part numeric version emitted by Dataverse solutions.""" + if not isinstance(value, str) or not _SOLUTION_VERSION_RE.fullmatch(value): + raise WorkdayDAContractError( + f"Invalid Dataverse solution version {value!r}; expected " + "four numeric components such as 2.1.0.0." + ) + return tuple(int(part) for part in value.split(".")) # type: ignore[return-value] + + +def architecture_for_agent_schema( + agent_schema_name: str, + *, + definition: Mapping[str, Any] | None = None, +) -> Mapping[str, Any] | None: + """Resolve the supported DA architecture for one exact agent schema.""" + active_definition = definition or load_definition() + normalized = agent_schema_name.casefold() + for architecture in active_definition["architectures"]: + if normalized in { + schema_name.casefold() + for schema_name in architecture["agentSchemaNames"] + }: + return architecture + return None + + +def assess_package_version( + package_flavor: str, + installed_version: str | None, + *, + definition: Mapping[str, Any] | None = None, +) -> PackageVersionAssessment: + """Evaluate one installed version against its product-owned policy.""" + active_definition = definition or load_definition() + try: + package = active_definition["packages"][package_flavor] + except KeyError as exc: + raise WorkdayDAContractError( + f"Unknown Workday DA package flavor: {package_flavor}" + ) from exc + policy = package["versionPolicy"] + minimum = policy["minimumInclusive"] + maximum = policy["maximumExclusive"] + if installed_version is None: + return PackageVersionAssessment( + "invalid", + None, + minimum, + maximum, + "The installed solution did not report a version.", + ) + try: + parsed = parse_solution_version(installed_version) + except WorkdayDAContractError as exc: + return PackageVersionAssessment( + "invalid", + installed_version, + minimum, + maximum, + str(exc), + ) + if policy["status"] != "enforced": + return PackageVersionAssessment( + "pending-policy", + installed_version, + minimum, + maximum, + "Package version policy is awaiting product confirmation.", + ) + parsed_minimum = parse_solution_version(minimum) + if parsed < parsed_minimum: + return PackageVersionAssessment( + "unsupported", + installed_version, + minimum, + maximum, + f"Installed version {installed_version} is below the supported " + f"minimum {minimum}.", + ) + if maximum is not None and parsed >= parse_solution_version(maximum): + return PackageVersionAssessment( + "unsupported", + installed_version, + minimum, + maximum, + f"Installed version {installed_version} is not below the supported " + f"maximum {maximum}.", + ) + return PackageVersionAssessment( + "supported", + installed_version, + minimum, + maximum, + f"Installed version {installed_version} is supported.", + ) + + +def retry_schedule( + failure_category: str, + operation_kind: str, + *, + definition: Mapping[str, Any] | None = None, +) -> tuple[int, ...]: + """Return bounded retry delays for a safe operation, else no retries.""" + active_definition = definition or load_definition() + policy = active_definition["failurePolicy"] + try: + category = policy["categories"][failure_category] + except KeyError as exc: + raise WorkdayDAContractError( + f"Unknown Workday DA failure category: {failure_category}" + ) from exc + if operation_kind not in {"read", "idempotent"}: + if operation_kind != "mutation": + raise WorkdayDAContractError( + f"Unknown Workday DA operation kind: {operation_kind}" + ) + return () + if not category["retryable"]: + return () + retry_count = max(policy["maxAttempts"] - 1, 0) + return tuple(policy["backoffSeconds"][:retry_count]) diff --git a/solutions/ess-maker-skills/scripts/workday_da_state.py b/solutions/ess-maker-skills/scripts/workday_da_state.py new file mode 100644 index 000000000..c814a71cd --- /dev/null +++ b/solutions/ess-maker-skills/scripts/workday_da_state.py @@ -0,0 +1,916 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Deterministic persisted-state operations for Workday DA setup.""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import sys +import tempfile +from typing import Any + +import portalocker + +from workday_da_contract import ( + DEFINITION_ROOT, + WorkdayDAContractError, + load_definition, + validate_state, +) + + +_VALID_RESULTS = { + value.casefold(): value + for value in ( + "Passed", + "Failed", + "Error", + "Warning", + "Manual", + "NotConfigured", + "Skipped", + ) +} +_STATUS_RE = re.compile( + r"status: (pending|in-progress|done|blocked)(?= -->)" +) +_ID_RE = re.compile(r"\bid: (?PDA[1-5]\.[1-9][0-9]*)\b") + + +class WorkdayDAStateError(RuntimeError): + """Raised when Workday DA state cannot be read, migrated, or written.""" + + +class WorkdayDAStateConflictError(WorkdayDAStateError): + """Raised when multiple state copies require explicit reconciliation.""" + + +class WorkdayDAStateLockError(WorkdayDAStateError): + """Raised when another Workday DA state writer holds the lock.""" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _atomic_write_text(path: Path, content: str) -> None: + """Write one file through a durable same-directory temporary file.""" + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + stream.flush() + try: + os.fsync(stream.fileno()) + except OSError: + pass + os.replace(temporary_path, path) + finally: + temporary_path.unlink(missing_ok=True) + + +def _read_json_object(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + document = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise WorkdayDAStateError( + f"Workday DA state is not valid JSON: {path}: {exc}" + ) from exc + except OSError as exc: + raise WorkdayDAStateError( + f"Workday DA state could not be read: {path}: {exc}" + ) from exc + if not isinstance(document, dict): + raise WorkdayDAStateError( + f"Workday DA state must contain a JSON object: {path}" + ) + return document + + +def _parse_json_argument(value: str | None, label: str) -> dict[str, Any] | None: + if value is None: + return None + try: + document = json.loads(value) + except json.JSONDecodeError as exc: + raise WorkdayDAStateError(f"{label} is not valid JSON: {exc}") from exc + if not isinstance(document, dict): + raise WorkdayDAStateError(f"{label} must be a JSON object") + return document + + +class WorkdayDAStateStore: + """Serialize and validate all Workday DA persisted-state mutations.""" + + def __init__( + self, + workspace_root: Path, + *, + definition: dict[str, Any] | None = None, + lock_timeout: float = 5.0, + ) -> None: + self.workspace_root = workspace_root.resolve() + self.definition = definition or load_definition() + self.lock_timeout = lock_timeout + state = self.definition["state"] + self.config_path = self.workspace_root / Path(state["configPath"]) + self.checklist_path = self.workspace_root / Path(state["checklistPath"]) + self.legacy_checklist_paths = [ + self.workspace_root / Path(path) + for path in state["legacyChecklistPaths"] + ] + self.lock_path = self.config_path.with_name("state.lock") + self.foundation_config_path = self.workspace_root / ".local" / "config.json" + self.template_path = DEFINITION_ROOT / "tasks.md" + self.step_by_id = { + step["id"]: step for step in self.definition["steps"] + } + + @contextmanager + def _locked(self): + self.lock_path.parent.mkdir(parents=True, exist_ok=True) + try: + with portalocker.Lock( + str(self.lock_path), + mode="a+", + timeout=self.lock_timeout, + encoding="utf-8", + ): + yield + except portalocker.exceptions.LockException as exc: + raise WorkdayDAStateLockError( + "Another /connect workday session is updating this workspace. " + "Wait for it to finish, then retry." + ) from exc + + def _migrate_legacy_checklist(self) -> bool: + existing_legacy = [ + path for path in self.legacy_checklist_paths if path.exists() + ] + if self.checklist_path.exists() and existing_legacy: + raise WorkdayDAStateConflictError( + "Both canonical and legacy Workday DA checklists exist. " + "Reconcile them manually; the state helper will not overwrite " + "or delete either copy." + ) + if len(existing_legacy) > 1: + raise WorkdayDAStateConflictError( + "Multiple legacy Workday DA checklists exist. Reconcile them " + "before continuing." + ) + if not existing_legacy: + return False + self.checklist_path.parent.mkdir(parents=True, exist_ok=True) + try: + existing_legacy[0].replace(self.checklist_path) + except OSError as exc: + raise WorkdayDAStateError( + "The legacy Workday DA checklist could not be moved to its " + f"canonical location: {exc}" + ) from exc + return True + + def _task_states(self, path: Path) -> dict[str, str]: + if not path.exists(): + return {} + states: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + identifier = _ID_RE.search(line) + status = _STATUS_RE.search(line) + if identifier and status: + states[identifier.group("id")] = status.group(1) + return states + + def _default_step_state( + self, + step: dict[str, Any], + *, + state: str = "pending", + ) -> dict[str, Any]: + checkpoints = step["checkpoints"] + return { + "state": state, + "checkpoint": checkpoints[0] if checkpoints else None, + "gate": step["gate"], + "verifiedBy": None, + } + + def _migrate_config( + self, + document: dict[str, Any], + *, + checklist_states: dict[str, str] | None = None, + ) -> dict[str, Any]: + migrated = dict(document) + definition_version = self.definition["definitionVersion"] + state_schema_version = self.definition["stateSchemaVersion"] + for field, current in ( + ("definitionVersion", definition_version), + ("stateSchemaVersion", state_schema_version), + ): + existing = migrated.get(field) + if existing in (None, 0): + migrated[field] = current + elif existing != current: + raise WorkdayDAStateError( + f"Unsupported Workday DA {field} {existing}; this kit " + f"supports version {current}." + ) + + migrated.setdefault("status", "in-progress") + setup_status = migrated.setdefault("setupStatus", {}) + if not isinstance(setup_status, dict): + raise WorkdayDAStateError("Workday DA setupStatus must be an object") + checklist_states = checklist_states or {} + for step in self.definition["steps"]: + step_id = step["id"] + row = setup_status.get(step_id) + if row is None: + setup_status[step_id] = self._default_step_state( + step, + state=checklist_states.get(step_id, "pending"), + ) + continue + if not isinstance(row, dict): + raise WorkdayDAStateError( + f"Workday DA setupStatus.{step_id} must be an object" + ) + row = dict(row) + row.setdefault("state", checklist_states.get(step_id, "pending")) + row["checkpoint"] = ( + step["checkpoints"][0] if step["checkpoints"] else None + ) + row["gate"] = step["gate"] + row.setdefault("verifiedBy", None) + setup_status[step_id] = row + self._validate_canonical_config(migrated) + return migrated + + def _validate_canonical_config(self, config: dict[str, Any]) -> None: + try: + validate_state(config) + except WorkdayDAContractError as exc: + raise WorkdayDAStateError(str(exc)) from exc + for field in ("definitionVersion", "stateSchemaVersion"): + expected = self.definition[field] + if config.get(field) != expected: + raise WorkdayDAStateError( + f"Workday DA {field} must be {expected}, got " + f"{config.get(field)!r}." + ) + actual_steps = set(config["setupStatus"]) + expected_steps = set(self.step_by_id) + if actual_steps != expected_steps: + missing = expected_steps - actual_steps + unknown = actual_steps - expected_steps + details = [] + if missing: + details.append("missing " + ", ".join(sorted(missing))) + if unknown: + details.append("unknown " + ", ".join(sorted(unknown))) + raise WorkdayDAStateError( + "Workday DA setupStatus does not match the active definition: " + + "; ".join(details) + ) + for step_id, step in self.step_by_id.items(): + row = config["setupStatus"][step_id] + expected_checkpoint = ( + step["checkpoints"][0] if step["checkpoints"] else None + ) + if row["checkpoint"] != expected_checkpoint: + raise WorkdayDAStateError( + f"Workday DA step {step_id} has checkpoint " + f"{row['checkpoint']!r}; expected {expected_checkpoint!r}." + ) + if row["gate"] != step["gate"]: + raise WorkdayDAStateError( + f"Workday DA step {step_id} has gate {row['gate']!r}; " + f"expected {step['gate']!r}." + ) + + def _render_checklist(self, config: dict[str, Any]) -> str: + lines = self.template_path.read_text(encoding="utf-8").splitlines() + rendered: list[str] = [] + seen: set[str] = set() + for line in lines: + identifier = _ID_RE.search(line) + if not identifier: + rendered.append(line) + continue + step_id = identifier.group("id") + if step_id not in self.step_by_id: + raise WorkdayDAStateError( + f"Checklist template contains unknown step {step_id}" + ) + state = config["setupStatus"][step_id]["state"] + updated = _STATUS_RE.sub(f"status: {state}", line) + if updated == line and not _STATUS_RE.search(line): + raise WorkdayDAStateError( + f"Checklist template row {step_id} has no status marker" + ) + if rendered: + marker = "x" if state == "done" else " " + rendered[-1] = re.sub( + r"^- \[[ x]\]", + f"- [{marker}]", + rendered[-1], + count=1, + ) + rendered.append(updated) + seen.add(step_id) + missing = self.step_by_id.keys() - seen + if missing: + raise WorkdayDAStateError( + "Checklist template is missing steps: " + + ", ".join(sorted(missing)) + ) + return "\n".join(rendered) + "\n" + + def _write_config(self, config: dict[str, Any]) -> None: + self._validate_canonical_config(config) + serialized = json.dumps(config, indent=2, ensure_ascii=False) + "\n" + _atomic_write_text(self.config_path, serialized) + + def _write_checklist(self, config: dict[str, Any]) -> None: + _atomic_write_text(self.checklist_path, self._render_checklist(config)) + + def _load_for_mutation(self) -> tuple[dict[str, Any], bool]: + migrated_checklist = self._migrate_legacy_checklist() + checklist_states = self._task_states(self.checklist_path) + config = self._migrate_config( + _read_json_object(self.config_path), + checklist_states=checklist_states, + ) + return config, migrated_checklist + + def initialize(self) -> dict[str, Any]: + """Migrate legacy state and create validated canonical config/state.""" + with self._locked(): + config, migrated_checklist = self._load_for_mutation() + self._write_config(config) + if not migrated_checklist: + self._write_checklist(config) + return config + + def reconcile(self) -> dict[str, Any]: + """Repair the derived checklist from authoritative validated config.""" + with self._locked(): + config, _ = self._load_for_mutation() + self._write_config(config) + self._write_checklist(config) + return config + + def validate(self) -> dict[str, Any]: + """Validate canonical config without changing any file.""" + config = _read_json_object(self.config_path) + if not config: + raise WorkdayDAStateError( + "Workday DA state does not exist; initialize it first." + ) + self._validate_canonical_config(config) + return config + + def customer_status(self) -> dict[str, Any]: + """Project internal step state into the concise customer journey.""" + config = self.validate() + milestones: list[dict[str, Any]] = [] + for milestone in self.definition["customerMilestones"]: + step_states = [ + config["setupStatus"][step_id]["state"] + for step_id in milestone["stepIds"] + ] + if all(state == "done" for state in step_states): + state = "done" + elif any(state == "blocked" for state in step_states): + state = "blocked" + elif any(state != "pending" for state in step_states): + state = "in-progress" + else: + state = "pending" + milestones.append( + { + "id": milestone["id"], + "title": milestone["title"], + "description": milestone["description"], + "state": state, + } + ) + next_milestone = next( + ( + milestone + for milestone in milestones + if milestone["state"] != "done" + ), + None, + ) + return { + "status": config["status"], + "milestones": milestones, + "nextMilestoneId": ( + next_milestone["id"] if next_milestone is not None else None + ), + } + + def _normalize_result(self, result: str | None) -> str | None: + if result is None: + return None + normalized = _VALID_RESULTS.get(result.casefold()) + if normalized is None: + raise WorkdayDAStateError( + f"Unsupported checkpoint result: {result}" + ) + return normalized + + def _automatic_evidence( + self, + *, + step: dict[str, Any], + result: str | None, + result_source: str, + ) -> dict[str, Any] | None: + if result in {"Failed", "Error"}: + return { + "outcome": result.upper(), + "provenance": result_source, + "note": "The current verification did not pass.", + "capturedAt": _utc_now(), + "failureCategory": "verification-failed", + "retryable": False, + "attemptCount": 1, + } + if ( + step["gate"] == "prog" + and result == "Passed" + and result_source != "external" + ): + checkpoint = ( + step["checkpoints"][0] + if step["checkpoints"] + else "external verification" + ) + return { + "outcome": "PASSED", + "provenance": result_source, + "note": f"{checkpoint} passed.", + "capturedAt": _utc_now(), + } + if step["gate"] == "advisory": + return { + "outcome": (result or "REVIEWED").upper(), + "provenance": "advisory", + "note": "The advisory result was shown to the operator.", + "capturedAt": _utc_now(), + } + return None + + def _resulting_state( + self, + *, + step: dict[str, Any], + result: str | None, + result_source: str, + ack: bool, + evidence: dict[str, Any] | None, + ) -> str: + if result in {"Failed", "Error"} and step["gate"] != "advisory": + return "blocked" + gate = step["gate"] + if gate == "advisory": + return "done" + if gate == "prog": + if result != "Passed": + return "in-progress" + if result_source == "external" and evidence is None: + return "in-progress" + return "done" + if gate in {"manual", "attest"}: + return "done" if ack and evidence is not None else "in-progress" + raise WorkdayDAStateError(f"Unsupported gate for {step['id']}: {gate}") + + def _validate_readiness_evidence( + self, + step_id: str, + *, + ack: bool, + evidence: dict[str, Any] | None, + ) -> None: + final_step_id = self.definition["completion"]["finalStepId"] + if step_id != final_step_id or not ack or evidence is None: + return + scenario = evidence.get("scenario") + if not isinstance(scenario, dict): + raise WorkdayDAStateError( + "Final Workday readiness evidence must include a structured " + "scenario record." + ) + required_truth = ( + scenario.get("nonMakerTestUserConfirmed") is True + and scenario.get("agentSharedWithTestUser") is True + and scenario.get("signedInUserConfirmed") is True + and scenario.get("realWorkdayDataConfirmed") is True + and scenario.get("connectionPromptObserved") is False + and scenario.get("unexpectedSignIn") is False + ) + if not required_truth: + raise WorkdayDAStateError( + "Final Workday readiness requires a shared non-maker test " + "employee, real Workday data, and no connection or additional " + "sign-in prompt." + ) + + def _dependent_ids(self, step_id: str) -> set[str]: + dependents: set[str] = set() + changed = True + while changed: + changed = False + for candidate in self.definition["steps"]: + candidate_id = candidate["id"] + if candidate_id == step_id or candidate_id in dependents: + continue + dependencies = set(candidate["dependsOn"]) + if step_id in dependencies or dependencies & dependents: + dependents.add(candidate_id) + changed = True + return dependents + + def _regress_rows( + self, + config: dict[str, Any], + step_ids: set[str], + *, + root_step_id: str, + root_state: str, + ) -> None: + for step_id in step_ids: + row = config["setupStatus"][step_id] + row["state"] = root_state if step_id == root_step_id else "in-progress" + row["verifiedBy"] = None + row.pop("evidence", None) + + def _recompute_provider_status(self, config: dict[str, Any]) -> None: + required = self.definition["completion"]["requiredStepIds"] + final_step = self.definition["completion"]["finalStepId"] + setup_status = config["setupStatus"] + revalidation = config.get("revalidation") + revalidation_pending = bool( + isinstance(revalidation, dict) + and revalidation.get("requiredStepIds") + ) + if ( + not revalidation_pending + and all( + setup_status[step_id]["state"] == "done" + for step_id in required + ) + ): + config["status"] = self.definition["completion"]["providerStatus"] + elif all( + setup_status[step_id]["state"] == "done" + for step_id in required + if step_id != final_step + ): + config["status"] = "configured" + else: + config["status"] = "in-progress" + + def _scope_fingerprint( + self, + step: dict[str, Any], + config: dict[str, Any], + ) -> str: + foundation = _read_json_object(self.foundation_config_path) + scope = {} + for field in step["scopeFields"]: + if field in config: + value = config.get(field) + else: + value = foundation.get(field) + scope[field] = value + serialized = json.dumps( + scope, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + def revalidation_plan(self) -> dict[str, Any]: + """Create a durable resume plan and regress scope-stale manual rows.""" + with self._locked(): + config, _ = self._load_for_mutation() + actions = [] + required_programmatic = [] + stale_manual: set[str] = set() + for step in self.definition["steps"]: + step_id = step["id"] + row = config["setupStatus"][step_id] + if row["state"] != "done": + continue + evidence = row.get("evidence") + saved_fingerprint = ( + evidence.get("scopeFingerprint") + if isinstance(evidence, dict) + else None + ) + current_fingerprint = self._scope_fingerprint(step, config) + if step["gate"] == "prog": + required_programmatic.append(step_id) + actions.append( + { + "stepId": step_id, + "owner": step["owner"], + "mode": ( + "checkpoint" + if step["checkpoints"] + else "external-verification" + ), + "checkpoints": step["checkpoints"], + "reason": ( + "scope-changed" + if saved_fingerprint != current_fingerprint + else "live-recheck" + ), + } + ) + elif saved_fingerprint != current_fingerprint: + stale_manual.add(step_id) + actions.append( + { + "stepId": step_id, + "owner": step["owner"], + "mode": "manual-evidence-stale", + "checkpoints": step["checkpoints"], + "reason": "scope-changed", + } + ) + + if stale_manual: + affected = set(stale_manual) + for step_id in stale_manual: + affected.update(self._dependent_ids(step_id)) + for step_id in affected: + row = config["setupStatus"][step_id] + row["state"] = "in-progress" + row["verifiedBy"] = None + row.pop("evidence", None) + + if required_programmatic: + config["revalidation"] = { + "requiredStepIds": required_programmatic, + "createdAt": _utc_now(), + } + else: + config.pop("revalidation", None) + self._recompute_provider_status(config) + self._write_config(config) + self._write_checklist(config) + return {"actions": actions, "config": config} + + def update_row( + self, + step_id: str, + *, + checkpoint_result: str | None, + result_source: str = "flightcheck", + ack: bool = False, + evidence: dict[str, Any] | None = None, + gate_evidence: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Apply one gate transition and persist config before its derived view.""" + if step_id not in self.step_by_id: + raise WorkdayDAStateError(f"Unknown Workday DA step: {step_id}") + if result_source not in { + "flightcheck", + "external", + "user-acknowledgement", + "advisory", + }: + raise WorkdayDAStateError( + f"Unsupported Workday DA result source: {result_source}" + ) + result = self._normalize_result(checkpoint_result) + step = self.step_by_id[step_id] + evidence = evidence or self._automatic_evidence( + step=step, + result=result, + result_source=result_source, + ) + self._validate_readiness_evidence( + step_id, + ack=ack, + evidence=evidence, + ) + resulting_state = self._resulting_state( + step=step, + result=result, + result_source=result_source, + ack=ack, + evidence=evidence, + ) + + with self._locked(): + config, _ = self._load_for_mutation() + if resulting_state == "done": + incomplete_dependencies = [ + dependency + for dependency in step["dependsOn"] + if config["setupStatus"][dependency]["state"] != "done" + ] + if incomplete_dependencies: + raise WorkdayDAStateError( + f"Cannot complete {step_id}; prerequisites are not " + "done: " + ", ".join(incomplete_dependencies) + ) + existing_state = config["setupStatus"][step_id]["state"] + row = config["setupStatus"][step_id] + row["state"] = resulting_state + row["checkpoint"] = ( + step["checkpoints"][0] if step["checkpoints"] else None + ) + row["gate"] = step["gate"] + if resulting_state == "done": + row["verifiedBy"] = { + "prog": "programmatic", + "manual": "attested", + "attest": "attested", + "advisory": "reviewed", + }[step["gate"]] + if evidence is not None: + evidence = dict(evidence) + evidence["scopeFingerprint"] = self._scope_fingerprint( + step, config + ) + row["evidence"] = evidence + else: + row["verifiedBy"] = None + row.pop("evidence", None) + if evidence is not None and resulting_state == "blocked": + row["evidence"] = evidence + if gate_evidence is not None: + row["gateEvidence"] = gate_evidence + + revalidation = config.get("revalidation") + if isinstance(revalidation, dict): + required = revalidation.get("requiredStepIds") + if isinstance(required, list) and step_id in required: + revalidation["requiredStepIds"] = [ + candidate + for candidate in required + if candidate != step_id + ] + if not revalidation["requiredStepIds"]: + config.pop("revalidation", None) + + if resulting_state != "done" and existing_state == "done": + self._regress_rows( + config, + self._dependent_ids(step_id), + root_step_id=step_id, + root_state=resulting_state, + ) + self._recompute_provider_status(config) + self._write_config(config) + try: + self._write_checklist(config) + except Exception as exc: + raise WorkdayDAStateError( + "Workday DA config was saved, but its checklist view could " + "not be refreshed. Run the reconcile command before " + "continuing." + ) from exc + return config + + def regress_row( + self, + step_id: str, + *, + blocked: bool = False, + ) -> dict[str, Any]: + """Regress one row and all transitive dependents.""" + if step_id not in self.step_by_id: + raise WorkdayDAStateError(f"Unknown Workday DA step: {step_id}") + with self._locked(): + config, _ = self._load_for_mutation() + affected = self._dependent_ids(step_id) | {step_id} + self._regress_rows( + config, + affected, + root_step_id=step_id, + root_state="blocked" if blocked else "in-progress", + ) + self._recompute_provider_status(config) + self._write_config(config) + self._write_checklist(config) + return config + + +def _summary(config: dict[str, Any]) -> str: + counts: dict[str, int] = {} + for row in config["setupStatus"].values(): + counts[row["state"]] = counts.get(row["state"], 0) + 1 + return json.dumps( + {"status": config["status"], "steps": counts}, + sort_keys=True, + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Manage canonical Workday DA setup state." + ) + parser.add_argument( + "--root", + type=Path, + default=Path.cwd(), + help="Workspace root containing .local (default: current directory).", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("initialize") + subparsers.add_parser("reconcile") + subparsers.add_parser("validate") + subparsers.add_parser("customer-status") + subparsers.add_parser("revalidation-plan") + + update = subparsers.add_parser("update-row") + update.add_argument("--step-id", required=True) + update.add_argument("--checkpoint-result") + update.add_argument( + "--result-source", + choices=[ + "flightcheck", + "external", + "user-acknowledgement", + "advisory", + ], + default="flightcheck", + ) + update.add_argument("--ack", action="store_true") + update.add_argument("--evidence-json") + update.add_argument("--gate-evidence-json") + + regress = subparsers.add_parser("regress-row") + regress.add_argument("--step-id", required=True) + regress.add_argument("--blocked", action="store_true") + return parser + + +def main() -> int: + args = _parser().parse_args() + store = WorkdayDAStateStore(args.root) + try: + if args.command == "initialize": + config = store.initialize() + elif args.command == "reconcile": + config = store.reconcile() + elif args.command == "validate": + config = store.validate() + elif args.command == "customer-status": + print(json.dumps(store.customer_status(), sort_keys=True)) + return 0 + elif args.command == "revalidation-plan": + plan = store.revalidation_plan() + print(json.dumps(plan["actions"], sort_keys=True)) + return 0 + elif args.command == "update-row": + config = store.update_row( + args.step_id, + checkpoint_result=args.checkpoint_result, + result_source=args.result_source, + ack=args.ack, + evidence=_parse_json_argument( + args.evidence_json, "evidence-json" + ), + gate_evidence=_parse_json_argument( + args.gate_evidence_json, "gate-evidence-json" + ), + ) + else: + config = store.regress_row( + args.step_id, + blocked=args.blocked, + ) + except (WorkdayDAStateError, WorkdayDAContractError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + print(_summary(config)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index df8406c24..f7d417775 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -58,8 +58,8 @@ Workday routes by architecture before package detection: CEA per-agent lifecycle state is stored at `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. DA Workday state - is stored in `.local/connect/workday-da/config.json` and - `.local/setup/workday-da/tasks.md`. + is stored together in `.local/connect/workday-da/config.json` and + `.local/connect/workday-da/tasks.md`. Each integration's steps.md and config.json persist after completion. Running `/connect` again lets the user add a different integration diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index cf323e978..7762aa828 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -11,11 +11,20 @@ Build a list of connected integrations (if any): - **ServiceNow** — connected if `.local/connect/servicenow/steps.md` exists and all items are checked. -- **Workday** — connected only if - `.local/connect/workday/agents/{active-agent-slug}/lifecycle.json` exists, - its `agentSlug` exactly matches the active agent, and every phase is `done`. - Shared provider setup state is not agent connection state and must not make - a sibling or newly selected agent appear connected. +- **Workday** — connected when either architecture's own completion contract + passes: + - **CEA:** `.local/connect/workday/agents/{active-agent-slug}/lifecycle.json` + exists, its `agentSlug` exactly matches the active agent, and every phase + is `done`. + - **DA:** the active agent resolves to a supported ESS DA HR schema, + `.local/connect/workday-da/config.json` has `status: "ready"`, and every + step listed in `workday-da.definition.json` + `completion.requiredStepIds` is `done`. + + Never use shared DA provider state to label an ESS DA IT, CEA, sibling, or + unresolved agent as connected. The DA lifecycle performs full live + revalidation when the maker selects Workday again; this summary reflects the + last successfully verified persisted state. --- @@ -270,8 +279,19 @@ Please select the ESS HR Agent or contact your administrator. Stop immediately without creating Workday state or entering a lifecycle. For `gptagent_copilotforemployeeselfservicehr` or the legacy -`msdyn_copilotforemployeeselfservicedahr` alias, read -`src/skills/setup/workday-da/SKILL.md` and follow it. That setup uses +`msdyn_copilotforemployeeselfservicedahr` alias, show: + +**Message:** + +Workday setup path selected: + +- Agent: **{ACTIVE_AGENT_DISPLAY_NAME}** +- Architecture: **Declarative Agent — ESS HR** +- Authentication: **Microsoft Entra ID Integrated** + +**End message.** + +Read `src/skills/setup/workday-da/SKILL.md` and follow it. That setup uses `WD-DA-PKG-001`. Do not create CEA Workday lifecycle state or run `WD-PKG-001`: DA packages share some Workday connection-reference names with CEA, so the CEA package fingerprint is not an architecture discriminator. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md index 3c2911dea..496f2846b 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md @@ -1,3 +1,10 @@ +--- +name: connect-workday +description: >- + Connect an installed Workday extension to an Employee Self-Service agent. + Use for live lifecycle discovery, agent wiring, validation, resume, and rollback. +--- + # Connect Workday (already installed) Entry point for connecting this agent to a Workday extension that is diff --git a/solutions/ess-maker-skills/src/skills/setup/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/SKILL.md index ac20b04d0..31b965d00 100644 --- a/solutions/ess-maker-skills/src/skills/setup/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/SKILL.md @@ -1,6 +1,14 @@ # Hybrid Workday Extension Setup +This file is only the boundary for an explicit request to configure the retired +hybrid Workday extension. It is **not** the entry point for `/connect workday` +or `/connect-workday`. + +If this file is reached from either Workday connect command, immediately read +`src/skills/connect/SKILL.md` and follow its architecture-aware routing. Stop +processing this file; do not show the hybrid-unavailable message below. + Hybrid Workday keeps its flows, connections, plugins, template configurations, and environment configuration in a separately owned Dataverse-backed extension while the agent itself uses the DA-GA platform. @@ -14,10 +22,7 @@ Hybrid Workday extension setup is not available in this release. Your DA-GA agent setup is unchanged, and no Dataverse environment, solution, connection, or flow was modified. -If a hybrid Workday extension is already configured and its Dataverse endpoint -is recorded in this workspace, `/backup-template-configs` and -`/restore-template-configs` remain available for its reference-data -customisations. +Use `/connect workday` to connect Workday to a supported ESS HR agent. **End message.** diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md index e9f7c46fc..cc5f172a9 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -1,10 +1,37 @@ +--- +name: connect-workday-da +description: >- + Connect Workday to an Employee Self-Service Declarative Agent HR deployment. + Use for package installation, Entra SSO, Workday tenant setup, Power Platform + connections and flows, authorization, resume, drift repair, and readiness validation. +--- + # Workday Connect (DA) — Orchestrator -Every **Message** block is the exact text to show the user. Copy it verbatim. Do +Every **Message** block is the exact text to show the user. + +## Playbook map + +- Package: [`install-extension.md`](install-extension.md) +- Microsoft Entra: [`provision-entra-app.md`](provision-entra-app.md) +- Workday tenant: [`configure-tenant.md`](configure-tenant.md) +- Power Platform and agent: [`configure-power-platform.md`](configure-power-platform.md) +- Readiness: [`verify-connection.md`](verify-connection.md) +- State transitions: [`shared/checklist-updater.md`](shared/checklist-updater.md) +- Permission gates: [`shared/permission-gate.md`](shared/permission-gate.md) +- Persisted state: [`shared/config-schema.md`](shared/config-schema.md) +- Executable definition: [`workday-da.definition.json`](workday-da.definition.json) Copy it verbatim. Do not rephrase, add commentary, or tell the user what tools you are calling or what files you are reading. +These checked-in playbooks and the executable definition are the controlled +sources for this flow. Do not browse for, merge in, or improvise setup steps +from unrelated web pages, LMC articles, prior chat transcripts, or another +Workday architecture. If a required detail is absent or conflicts with these +files, stop at that step and report the missing decision instead of inventing +instructions. + This router sequences the five Workday connect steps for the **ESS HR agent**, using the master checklist as a **resume-aware spine**: it renders the working checklist on first run, resumes at @@ -41,6 +68,42 @@ because this file must remain safe if invoked directly. --- +## V1 identity boundary + +This release supports only the Workday connection option named **Microsoft +Entra ID Integrated**. Do not present an identity-provider decision tree and do +not configure direct Workday federation through Okta, Ping, or another +identity provider. + +If the user says their Workday tenant is directly federated to a provider other +than Microsoft Entra ID, show: + +**Message:** + +This version of Workday setup supports **Microsoft Entra ID Integrated** +authentication only. Direct Workday federation through Okta, Ping, or another +identity provider is outside the V1 scope, so I won't change this environment. +Contact your Workday and identity administrators before continuing. + +**End message.** + +Stop without creating or updating Workday state. + +When this file is invoked directly instead of through `/connect workday`, show +the same customer-facing routing confirmation defined by the Workday branch in +`src/skills/connect/step1.md` before the readiness briefing. Resolve and enforce +the architecture, authentication mode, and canonical state path internally, +but do not expose Git revisions or local file-system paths in the customer +conversation. + +Use the same five-phase lifecycle and the same completion gates for +Development, Sandbox, and Production Power Platform environments. Environment +type never skips, reorders, or relaxes a Workday step. Only discovered +environment identifiers, tenant-specific values, and the ring-specific +Power Platform host may differ. + +--- + ## Handling Workday credentials — never put secrets in chat The Workday **password is a secret**. **Never** ask for it with a chat question @@ -63,100 +126,105 @@ ID, App ID URI) are safe to capture in chat — see [`shared/connection-fields.md`](./shared/connection-fields.md). The Workday **username** is likewise not masked (`"password": false`); only the password is. +Whenever a command starts device-code authentication, read the command output +and repeat the sign-in URL and one-time code as plain, copyable chat text. +Never require the user to copy them from an inline terminal. Do not repeat +access tokens, refresh tokens, passwords, or cookies. + --- ## Start -1. **Show the readiness briefing.** After the DA HR agent guard and routing - FlightChecks have passed, show this before creating or resuming the - checklist. Show it on every invocation so a resumed setup makes its - remaining administrator dependencies clear. +1. **Initialize the durable state.** The canonical internal checklist is + `.local/connect/workday-da/tasks.md`, next to the provider config it + represents. Initialize and validate both through the deterministic helper: + + ```powershell + python scripts/workday_da_state.py --root . initialize + ``` + + When the legacy `.local/setup/workday-da/tasks.md` exists, the helper will + move that exact file to the canonical path while preserving its contents and + timestamps. It refuses conflicting dual copies, migrates version fields, and + creates canonical config/checklist state when absent. Do not hand-edit + status markers or provider state. After a successful legacy move, the old + path is no longer used. + +2. **Revalidate saved completion.** Before selecting a resume row, create the + deterministic live-revalidation plan: + + ```powershell + python scripts/workday_da_state.py --root . revalidation-plan + ``` + + Complete every returned action before claiming readiness: + - `checkpoint` — rerun the listed checkpoint and persist its current result + through the checklist updater. + - `external-verification` — dispatch to the owning playbook's existing + read-only/post-write verification and persist that current result. + - `manual-evidence-stale` — the helper already regressed that row and its + dependents because the selected agent, tenant, endpoint, app, or + environment scope changed. Resume normally and request fresh evidence + when that row is reached. + + Do not show internal IDs from the plan. While programmatic revalidation is + outstanding, provider status remains `in-progress` even when saved rows are + still checked. + +3. **Show the concise customer journey.** Run: + + ```powershell + python scripts/workday_da_state.py --root . customer-status + ``` + + Map milestone states to markers: ✅ = `done`, 🔄 = `in-progress`, ⛔ = + `blocked`, and ⬜ = `pending`. Show only the seven customer milestones, not + the 21 internal rows, their IDs, checkpoint IDs, implementation files, or + completed-row evidence. + + On a completely fresh setup, precede the status with one sentence: **Message:** - Here's the plan for connecting Workday to your ESS HR agent. Some steps - require administrators outside the maker role, so involve them now if you - don't hold these permissions: - - | Phase | What we'll do | Who is needed | - | --- | --- | --- | - | Workday extension | Install or verify the Workday package for the ESS HR agent | Power Platform Environment Maker | - | Microsoft Entra | Configure Workday SSO, API permission, consent, user assignment, NameID, and SAML signing | Entra Application Administrator or Cloud Application Administrator; a consent-capable administrator if required | - | Workday tenant | Configure tenant security, the API client, functional areas, endpoints, authentication policy, and certificate trust | Workday Administrator | - | Power Platform connections | Configure Workday OAuthUser and Dataverse connections, shared parameters, bindings, and cloud flows | Power Platform Environment Maker | - | Agent authorization | Preview and run the Dataverse bot-to-flow authorization script | Power Platform Administrator with Dataverse System Administrator access | - | Network readiness | Allow the required Workday REST and SOAP hosts | InfoSec or network administrator | - | Topics and validation | Select Workday topics and validate a real signed-in employee scenario | Environment Maker, Workday test employee, and Workday Administrator if remediation is needed | - - I'll automate checks and supported changes where reliable APIs are - available. For Workday or portal-only settings, I'll give the responsible - administrator the exact steps and wait for confirmation. I won't mark the - environment ready until the signed-in Workday scenario succeeds. + Connecting Workday requires an Environment Maker, a Microsoft Entra + administrator, and a Workday administrator. I will save progress and tell + you when each person is needed. **End message.** -2. **Working copy.** If `.local/setup/workday-da/tasks.md` does not exist, render - it by copying the template `src/skills/setup/workday-da/tasks.md`. Do not - hand-edit its status markers — the shared checklist-updater writes them. + Do not repeat that sentence after any internal row has moved out of + `pending`. -3. **Resume point.** Read `setupStatus` in `.local/connect/workday-da/config.json` - (the durable source of truth; the tasks file is only the view). If the file or - the `setupStatus` key is missing, treat every row as `pending`. A row counts as - complete only when `setupStatus["{Step}"].state` is `"done"`. + **Message:** -4. **Show the checklist, then find where to resume.** Determine each item's state - from `setupStatus`: ✅ = `done`, 🔄 = `in-progress`, ⛔ = `blocked`, ⬜ = - `pending` or unset. Show the checklist **grouped exactly as in the template** — - the group headings and item titles below are verbatim from - `src/skills/setup/workday-da/tasks.md`; render every group and every item, - replacing each `{m}` with that item's marker. **Never show Step IDs or - checkpoint IDs.** + Workday connection progress: - **Message:** + | Milestone | Status | + | --- | --- | + | Preflight | {marker} | + | Microsoft Entra | {marker} | + | Workday administrator | {marker} | + | Connections | {marker} | + | Runtime configuration | {marker} | + | Network readiness | {marker} | + | Employee validation | {marker} | - Here's the checklist for connecting Workday to your agent: - - **1. Workday extension package** - - {m} Install the Workday extension package - - **2. Connect Microsoft Entra sign-in to Workday** - - {m} Set up Workday sign-in - - {m} Allow Power Platform to call Workday - - {m} Approve the sign-in permissions - - {m} Choose who can use Workday - - {m} Match the signed-in employee - - {m} Sign the Workday sign-in response - - {m} Confirm the correct Microsoft Entra tenant - - **3. Workday tenant configuration** - - {m} Register the Workday API client - - {m} Capture your Workday connection details - - {m} Verify employee SAML sign-in policy - - {m} Match the signing certificate - - **4. Power Platform and agent integration** - - {m} Create the Workday connection - - {m} Create the Microsoft Dataverse connection - - {m} Bind the extension connections - - {m} Turn on the Workday cloud flows - - {m} Connect Workday to the agent - - {m} Authorize the agent to use the Workday flows - - {m} Configure employee context and topics - - {m} Allow Workday through the firewall - - **5. Validate Workday readiness** - - {m} Validate a signed-in Workday scenario - - Picking up at: {title of the first item whose state is not `done`}. + Next: **{title of `nextMilestoneId`}**. **End message.** - Then walk the items in Step order (DA1.1, DA2.1 … DA5.1 — these IDs are - internal only), pick the first whose state is not `done`, and dispatch by that - Step in **Dispatch** below. A step's playbook may re-run its own idempotent - foundation steps (role gate, resource lookup) ahead of the resume item to - rehydrate in-memory state — follow the playbook's stated build order rather - than jumping straight into it. + If `nextMilestoneId` is null, omit the **Next** line. Do not render the + longer technical checklist unless the user explicitly asks for diagnostic + details. + +4. **Resume internally.** Read `setupStatus` in + `.local/connect/workday-da/config.json` (the durable source of truth; the + tasks file is only an internal compatibility view). Walk the technical rows + in Step order (DA1.1, DA2.1 … DA5.1), pick the first whose state is not + `done`, and dispatch by that Step in **Dispatch** below. A step's playbook may + re-run its own idempotent foundation steps to rehydrate in-memory state. + Follow that playbook's build order without showing those internal rows to the + customer. 5. If **every** item is `done`, also require provider `status` to be `"ready"` before showing **All done**. If every row is done but status is not ready, @@ -174,6 +242,16 @@ immediately — and **must not** batch those writes to the end of its run. This keeps progress crash-safe: if a step errors midway, the rows already verified stay complete and this router resumes at the first row that isn't. +**Failure and retry policy.** Classify failures using the categories in +`workday-da.definition.json`. Only `transient` failures from read-only or +explicitly idempotent operations may retry, using the definition's bounded +attempt count and backoff. Authentication, permission, validation, +unsupported-state, conflict, manual-action, and verification failures stop +with remediation. An `ambiguous-mutation` always stops for reconciliation; +never repeat a mutation when the prior outcome is unknown. Persist the safe +category, retryability, and attempt count in row evidence without raw service +responses or secrets. + ### DA1.1 — Install the Workday extension package (DA-1) Read `src/skills/setup/workday-da/install-extension.md` and follow it. That @@ -192,31 +270,30 @@ playbook role-gates (App / Cloud Application Administrator), instantiates and configures the Workday SSO gallery app, exposes the API scope and pre-authorizes the Workday connector, grants and consents the Graph permissions, assigns the enterprise app, sets the NameID mapping and SAML -signing option, and confirms single-tenant federation. It verifies each +signing option, and confirms single-tenant federation. It obtains one scoped +approval for the remaining Entra changes, then verifies each outcome (`WD-CONN-102`, `WD-ENTRA-SCOPE-001`, `WD-ENTRA-CONSENT-001`, `WD-ASSIGN-001`, `WD-ENTRA-NAMEID-001`, `WD-ENTRA-SIGNOPT-001`, `WD-CONN-010`) and updates rows **DA2.1**–**DA2.7** through the shared checklist-updater (DA2.1/DA2.6 manual and DA2.7 attest rows need acknowledgement). On resume it -always re-runs its role gate and DA2.1 (create the SSO app) first — both -idempotent — before the first incomplete row, since DA2.2–DA2.4 depend on the -in-memory app object id that only DA2.1 populates. +re-runs the role gate, scoped approval, and DA2.1 app lookup before the first +incomplete row, since DA2.2–DA2.4 depend on the in-memory app object id that +DA2.1 populates. When it returns, go back to **Start** to resume at the next unverified row. ### DA3.1 through DA3.4 — Configure the Workday tenant (DA-3) Read `src/skills/setup/workday-da/configure-tenant.md` and follow it. That -playbook role-gates (Workday Administrator, by attestation), records the -current single-tenant SAML federation before any change, uploads and verifies -the X.509 signing certificate (`WD-CONN-102`), edits Tenant Setup – Security, -registers the Workday API client and captures the connection fields -(`WD-API-CLIENT-001`), and verifies the signed-in employee SAML policy -(`WD-TENANT-001`) — updating rows **DA3.1**–**DA3.4** through the shared -checklist-updater. All four are manual Workday-admin tasks (attest / manual -gates) that need acknowledgement; `WD-API-CLIENT-001` and `WD-TENANT-001` -report `MANUAL`. On resume it always re-runs its role gate and the -single-tenant SAML pre-check first — both idempotent — before the first -incomplete row. +playbook confirms a Workday Administrator is available, protects the existing +single-tenant SAML federation, and presents one administrator work packet for +the X.509 signing certificate, Tenant Setup – Security, API client, connection +fields, and employee SAML policy. It captures one structured response and +updates rows **DA3.1**–**DA3.4** through the shared checklist-updater. Manual +outcomes remain individually evidenced internally, but manual-only +FlightChecks are not presented as customer verification. A different or +unknown active federation pauses the standard flow for the organization's +formal identity change process. When it returns, go back to **Start** to resume at the next unverified row. @@ -227,9 +304,11 @@ That playbook guides creation of the Workday and Dataverse connections, binds the installed solution references, activates the runtime flows, connects the flows to the agent with parameter sharing, applies checked-in script authorization, configures DA V2 employee context and topic selection, and -records firewall allowlisting. It updates rows **DA4.1**–**DA4.8** through the -shared checklist-updater. Manual and attestation rows require explicit -evidence; DA4.3, supported DA4.4 activation, and DA4.6 are programmatic. +shows a conditional network advisory. It obtains one scoped approval for the +remaining supported runtime helpers and updates rows **DA4.1**–**DA4.8** +through the shared checklist-updater. Manual rows require explicit evidence; +DA4.3, supported DA4.4 activation, and DA4.6 are programmatic, while DA4.8 is +non-blocking. When it returns, go back to **Start** to resume at DA5.1. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md index 94e2e29a9..f2c9f9a5a 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -2,9 +2,10 @@ # DA-4 — Configure Power Platform and Agent Integration Role: **Environment Maker**, with a **Power Platform Administrator** for -bot-to-flow authorization and **InfoSec/IT** for network allowlisting. This step -applies the Workday and Entra values captured earlier to the installed ESS DA HR -extension. It owns checklist rows **DA4.1 through DA4.8**. +bot-to-flow authorization. Involve **InfoSec/IT** only when organizational +network controls restrict the Workday hosts. This step applies the Workday and +Entra values captured earlier to the installed ESS DA HR extension. It owns +checklist rows **DA4.1 through DA4.8**. Every **Message** block is the exact text to show the user. Copy it verbatim. Do not claim that a manual portal setting was verified automatically. @@ -47,17 +48,27 @@ Open this environment's **Connections** page: 1. Select **New connection**, search for **Workday**, and create a connection using **Microsoft Entra ID Integrated** authentication. -3. Enter the values below. Complete the sign-in/consent window if one opens. +2. Enter the connection fields in the order shown below. +3. Complete the sign-in or consent window if one opens. 4. Wait until the Workday connection shows **Connected**. -Use the values captured earlier: +**Connection worksheet** -- Microsoft Entra resource URL: the Workday SAML identifier configured for - this tenant, not the `api://` application ID URI. -- OAuth token URL: `{oauthTokenUrl}`. -- Workday API client ID: `{oauthClientId}`. -- SOAP base URL: `{soapBaseUrl}`. -- REST base URL: `{restBaseUrl}`. It must end exactly at `/api`. +- Workday tenant: `{tenant}` *(verification context; enter it only if the form + displays a tenant field)* +- Authentication: `Microsoft Entra ID Integrated` + +| Connection form field | Value | +| --- | --- | +| Microsoft Entra resource URL | `http://www.workday.com/{tenant}` | +| OAuth token URL | `{oauthTokenUrl}` | +| Workday API client ID | `{oauthClientId}` | +| SOAP base URL | `{soapBaseUrl}` | +| REST base URL | `{restBaseUrl}` | + +The Microsoft Entra resource URL is the Workday SAML Identifier / Entity ID +configured for this tenant, not the `api://` application ID URI. The REST base +URL must end exactly at `/api`. **End message.** @@ -85,37 +96,121 @@ Re-read the ring-native connection inventory and confirm the Dataverse connection is `Connected` and belongs to this environment. Record DA4.2 with the connection name and environment in the evidence. +**Message:** + +The physical connections are created. Newly installed managed-solution flows +can still be **Off** at this point; that is expected until their connection +references are bound. Do not open the agent's Connection settings yet. I'll +bind both references first, then turn on and verify the Workday flows. + +**End message.** + +## DA4.2a — Approve the remaining runtime changes + +When any of DA4.3, DA4.4, DA4.6, or the programmatic UserContext portion of +DA4.7 remains incomplete, prepare one scoped automation plan for the current +environment and active agent. + +**Message:** + +The two connections are ready. I can now complete the supported runtime +configuration for this environment: + +- bind the Workday and Dataverse connection references; +- turn on the reviewed Workday cloud flows; +- authorize this agent to use those flows; and +- connect the employee-context setup topic to Workday User Context V2. + +Each operation will run a read-only preview first, apply only to the selected +environment and agent, and verify the result. Topic selection and connecting +the Workday flows in Copilot Studio remain manual. Continue with this runtime +plan? + +**End message.** + +Use `vscode_askQuestions` with **Continue** and **Cancel** options. Do not +preselect an answer. **Continue** approves all remaining helper operations in +the listed scope for this invocation. **Cancel** pauses without mutation. + +Show each helper's preview target names, but do not ask for another approval +when they match the approved environment, agent, connections, and reviewed +flow catalog. If any target or operation differs, stop and return here with a +new consolidated plan. + ## DA4.3 — Bind the extension connections -Bind the installed solution references programmatically: +Use the checked-in binding helper: + +`scripts/bind_workday_da_connections.py` + +Resolve `WORKDAY_DATAVERSE_URL`, `RING`, and the maker account from canonical +setup state; do not ask the maker to paste connection IDs. First preview: + +```powershell +python scripts/bind_workday_da_connections.py ` + --url "{WORKDAY_DATAVERSE_URL}" ` + --ring "{RING}" ` + --preferred-username "{MAKER_ACCOUNT}" +``` + +The helper uses the ring-aware PAC profile to discover connected physical +connections and the Dataverse Web API to read the two installed runtime +references. It fails closed when either connection or reference is missing or +ambiguous. If multiple connected connections exist for a connector, show their +safe display names and ask the maker which one to use, then rerun the preview +with `--workday-connection-id` and/or `--dataverse-connection-id`. -1. Resolve the physical Workday and Dataverse connection IDs created in - DA4.1–DA4.2. -2. PATCH `msdyn_sharedworkdaysoap_workdayruntime.connectionid` to the Workday - connection ID. -3. PATCH - `msdyn_sharedcommondataserviceforapps_workdayruntime.connectionid` to the - Dataverse connection ID. -4. Re-read both rows and confirm the IDs persisted. -5. Confirm neither reference points to a connection from a different - environment or user. +Show the `WORKDAY_DA_BINDING_PLAN_JSON` target display names. When they match +the approved DA4.2a scope, run the identical command with `--apply` without a +second approval. Do not translate or replace the helper with ad hoc PATCH +calls. -Preview the two target logical names and connection display names before -PATCHing. The authorization script does not perform this step. Record DA4.3 -only after the post-write verification passes. +The apply run must emit `WORKDAY_DA_BINDING_APPLIED_JSON`, report +`"verified": true`, and post-read: + +- `msdyn_sharedworkdaysoap_workdayruntime.connectionid` equal to the selected + Workday connection name; +- `msdyn_sharedcommondataserviceforapps_workdayruntime.connectionid` equal to + the selected Dataverse connection name. + +If either field remains empty, leave DA4.3 blocked and do not attempt flow +activation. The authorization script does not perform this binding. Record +DA4.3 only after the helper's post-write verification passes. ## DA4.4 — Turn on the Workday cloud flows +The AppSource installer installs the managed package but does not activate its +cloud flows. Seeing the Workday flows **Off** immediately after installation is +therefore expected and is not evidence that the physical connection failed. + Do not activate flows until DA4.1–DA4.3 are complete and the two installed runtime `connectionreference` rows have non-empty connection bindings. A flow whose references are unbound may activate but will fail at runtime. -Discover the Workday flows installed with the ESS DA HR extension when a -reliable DA-scoped listing is available. If they can be enabled through the -supported Power Platform API, preview the affected flows and ask for approval -before enabling them. +Use the checked-in activation helper: + +`scripts/activate_workday_da_flows.py` + +The reviewed flow catalog comes from the selected package in +`workday-da.definition.json`; do not discover targets by name prefix or include +similarly named flows from another solution. First preview: + +```powershell +python scripts/activate_workday_da_flows.py ` + --url "{WORKDAY_DATAVERSE_URL}" ` + --package-flavor "{PACKAGE_FLAVOR}" ` + --preferred-username "{MAKER_ACCOUNT}" +``` + +The helper fails closed when either runtime reference is unbound, a reviewed +flow is missing or duplicated, or a matched record is not a cloud flow. Show +the `WORKDAY_DA_FLOW_ACTIVATION_PLAN_JSON` actions. When they match the +approved DA4.2a scope and reviewed catalog, run the identical command with +`--apply` without a second approval. Do not replace the helper with an ad hoc +Dataverse PATCH. -Otherwise show: +The apply run must emit `WORKDAY_DA_FLOWS_ACTIVATED_JSON` and report +`"verified": true`. If the selected package has no reviewed flow catalog, show: **Message:** @@ -125,7 +220,10 @@ flows from other solutions. **End message.** -Record DA4.4 only after every target Workday flow is verified as active. +Record DA4.4 only after every target Workday flow is verified as active. If any +target flow cannot be turned on, show its exact activation error and return to +DA4.3 to verify both bindings. Never continue to agent Connection settings +while a required Workday flow is **Off**. ## DA4.5 — Connect the agent and share parameters @@ -152,10 +250,57 @@ values remain populated. This is agent/runtime wiring; solution-level binding does not replace it. Do not infer it from the physical connection inventory's `allowSharing` property. -Require explicit confirmation that every Workday flow entry is connected, -parameter sharing is enabled, the fields remain populated, and the connection -is connected. If a connection is **Stale** or **Needs attention**, reconnect it -before continuing. Record DA4.5 as manual. +After the maker saves the setting, verify the active agent rather than accepting +only a completion statement: + +```powershell +python scripts/flightcheck/cli.py ` + --checkpoint WD-DA-CONN-001 ` + --connect-config ".local/connect/workday-da/config.json" ` + --agent-slug "{ACTIVE_AGENT}" +``` + +Show the result per +[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0. + +- `PASSED` proves that every connected Workday reference exposed by the active + agent contains shared connection parameters. Update DA4.5 with + `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`, and + `RESULT_SOURCE="flightcheck"`. +- `FAILED` means at least one connected Workday reference does not contain + shared parameters. Show the named remediation, leave DA4.5 blocked, and + rerun the checkpoint after the maker saves the setting again. +- `NOT_CONFIGURED` means the active agent has no connected Workday reference. + Return to the start of DA4.5 and connect every Workday flow entry. +- `WARNING` or `SKIPPED` means the setting could not be read. Refresh the + active-agent authentication and retry. If the read remains unavailable, use + the definition's manual fallback only after explicit confirmation that every + Workday flow entry is connected, parameter sharing is enabled, the fields + remain populated, and the connection shows **Connected**. A **Stale** or + **Needs attention** connection must be reconnected first. + +After DA4.5 passes or its documented fallback is completed, show: + +**Message:** + +The Workday connection parameters are shared with the agent. After the +remaining setup is complete and the agent is published, validate that sharing +works for another employee: + +1. Share the agent with a test employee who is not the maker who created the + Workday connection. +2. Sign in as that employee and start a new conversation. +3. Run a read-only Workday question, such as checking a vacation balance. +4. Confirm the agent returns that employee's Workday data without showing a + **Connect**, consent, or additional sign-in prompt. +5. If a connection prompt appears, return to **Settings → Connection + settings**, save **Allow permission to share parameters** again, rerun this + verification, republish, and retry with a new conversation. + +The final Workday validation will ask you to confirm this non-maker test. Do +not use a write or approval scenario until the read-only check succeeds. + +**End message.** ## DA4.6 — Authorize the DA to use the Workday flows @@ -184,6 +329,11 @@ Resolve parameters instead of asking the maker to paste GUIDs: If the bot or workflow set cannot be resolved unambiguously, stop and explain which value is missing. Never guess or run the script with a partial flow set. +Do not run this script before DA4.5 is complete. Both invocations below occur +after every Workday flow is connected and **Allow permission to share +parameters** is enabled. The two invocations are preview and apply; they are not +"before sharing" and "after sharing" runs. + Before invoking the checked-in script, perform the same read-only delegated-authorization and team lookups documented by the script: @@ -196,9 +346,9 @@ delegated-authorization and team lookups documented by the script: require administrator remediation. The script also fails closed on these ambiguous records. -First run the script with `-WhatIf`, show the target organization, agent, and -flow display names, and obtain explicit approval. Then run the same command -without `-WhatIf`. +First run the script with `-WhatIf` and show the target organization, agent, +and flow display names. When they match the approved DA4.2a scope, run the same +command without `-WhatIf`; do not request another approval. The script's `-WhatIf` run may exit `1` after showing a correct `would create` or `would share` plan. This happens because its final verification checks for @@ -233,6 +383,56 @@ Inspect the installed DA package before changing the agent. Do not assume the CEA topic name or file shape. Identify the package's V2 signed-in-user context component that uses the Workday `/workers/me` path. +Use the checked-in UserContext helper: + +`scripts/configure_workday_da_user_context.py` + +Resolve the active agent's `botId`, the effective Dataverse URL, and the maker +account from canonical state. Do not ask the maker to paste an agent ID. First +preview: + +```powershell +python scripts/configure_workday_da_user_context.py ` + --url "{WORKDAY_DATAVERSE_URL}" ` + --bot-id "{BOT_ID}" ` + --preferred-username "{MAKER_ACCOUNT}" +``` + +The helper scopes every read to the exact active agent. It requires exactly one +**[Admin] - User Context - Setup** topic and exactly one +**Workday [System] - 1: Set User Context V2** target. It changes only an empty +or bare setup scaffold, reports an already-correct redirect as unchanged, and +refuses to overwrite custom or ambiguous content. + +Show the `WORKDAY_DA_USER_CONTEXT_PLAN_JSON` action. If the action is +`configure` and it matches the approved DA4.2a scope, rerun the identical +command with `--apply` without another approval. +The apply run must emit `WORKDAY_DA_USER_CONTEXT_APPLIED_JSON`, report +`"verified": true`, and confirm that the setup topic now redirects to the +installed target topic's exact schema name. The helper changes draft topic +content only; do not publish from this step. + +If the helper reports custom content, cannot authenticate, cannot identify the +two topics unambiguously, or fails post-write verification, do not force an +overwrite. Show: + +**Message:** + +**Switch the user-context topic to V2** + +1. In Microsoft Copilot Studio, open the active **ESS HR** agent. +2. Go to **Topics** and open **[Admin] - User Context - Setup**. +3. In the **Topic** node, select the existing topic reference and choose + **Select a topic**. +4. Search for `v2`. +5. Select **Workday [System] - 1: Set User Context V2**. +6. Save the topic. + +Tell me after the topic is saved. I will verify the redirect before completing +this setup step. + +**End message.** + Present these choices: 1. **Enable all Workday topics** — recommended for makers who want the complete @@ -251,28 +451,82 @@ topic list and obtain approval before changing anything. Confirm: - choosing **Enable all** enables every installed Workday business topic plus the required Workday system topics. +For the current ESS HR runtime package, the package-managed Workday system +topic catalog is: + +- **Workday [System] - 1: Set User Context V2** +- **Workday [System] - 1: Set Runtime Template Configurations** +- **Workday System ParseError** +- **Workday System Get CommonExecution** +- **Workday System Get REST Execution** +- **Workday System Get ReferenceData** +- **Workday System Refresh ReferenceData** +- **Workday System ManagerCheck** +- **Workday System AccessCheck** + +Use the installed component inventory as the runtime source of truth and match +these names exactly for the current package. If the active package flavor +exposes a different catalog, stop and report the package/version drift rather +than renaming, recreating, or guessing a substitute system topic. + Topic activation is server-only state and is not stored in the topic YAML. The current AgentBuilder client can fetch components, update the bot entity, import, and publish, but it has no proven per-component status mutation API. Until a supported API is added, do not guess a MinimalBot payload. Provide the equivalent Copilot Studio enablement steps, including the **Enable all** -selection, and record DA4.7 as manual after confirmation. +selection. + +After the maker confirms the selected topics and required system dependencies +are enabled, run: + +```powershell +python scripts/flightcheck/cli.py ` + --checkpoint WD-DA-CTX-001 ` + --connect-config ".local/connect/workday-da/config.json" ` + --agent-slug "{ACTIVE_AGENT}" +``` + +Show the result per +[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0. Do not +complete DA4.7 unless the checkpoint is `PASSED`; a pass proves the setup topic +redirects to the exact Workday V2 target and that target is enabled. DA4.7 +remains a manual gate because the selected business-topic set still requires +maker confirmation. Record the checkpoint result, explicit acknowledgement, +and safe evidence describing the selected topic mode without storing topic +contents. + +After the selected Workday topics and required dependencies are confirmed, +show: + +**Message:** + +The Workday topic selection is complete. Do not edit, rename, delete, or +repurpose package-managed Workday topics or their required system +dependencies. In an environment that also contains ServiceNow, do not change +ServiceNow or other package-managed system topics while configuring Workday. +Put customer-specific behavior in separate custom topics. + +**End message.** -## DA4.8 — Record firewall allowlisting +## DA4.8 — Review network restrictions **Message:** -Your InfoSec/IT team must allow outbound access from the Power Platform Workday -managed connectors to these Workday hosts: +The Workday connection uses these hosts: - REST: `{restBaseUrl host}` - SOAP: `{soapBaseUrl host}` -Has that allowlisting been put in place for this environment? +Most environments need no separate action. If your organization restricts +managed-connector destinations or Workday enforces network/IP restrictions, +share these hosts with the responsible Workday or network administrator before +employee validation. Otherwise continue. **End message.** -This is an attestation, not a local connectivity test. Record DA4.8 only after -explicit acknowledgement and captured evidence. +This is a non-blocking advisory. Record DA4.8 with `GATE="advisory"` after the +message is shown. Do not request an attestation and do not block setup solely +because no firewall change was required. If runtime validation later reports a +network restriction, return here and show the same hosts as remediation. Return to the orchestrator. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md index eabe63a8c..cfbf0678a 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -1,407 +1,221 @@ -# DA-3 — Configure the Workday Tenant - -Role: **Workday Administrator**. This step performs the Workday-tenant-side -configuration the simplified setup requires: the SAML X.509 signing certificate, -Tenant Setup – Security, the Workday API client, and the authentication policy. -It owns master-checklist rows **DA3.1 through DA3.4**. - -Depends on DA-2 (the Entra app must already exist — this step reads its -`entraAppId` / `appIdUri` and the activated signing-cert thumbprint). It is -**Workday-only**: none of these tasks is reachable through a Microsoft admin API, -and standing up a Workday connection to self-verify would be **circular** (it -needs the same Entra-app + tenant configuration the ESS agent itself needs). So -every step here is a **manual Workday-admin task**, and its flightcheck reports -`MANUAL` — it echoes what the operator captured and names the Workday screen to -verify, but it never marks a row done on its own. None of this differs from how a -CEA Employee Self-Service agent's Workday tenant is configured — the Workday side -of the connection doesn't know or care what agent architecture is calling it — -only the persisted state paths differ. - -Every **Message** block is the exact text to show the user. Copy it verbatim. Do -not rephrase, add commentary, or tell the user what tools you are calling or what -files you are reading. **Never** show internal variable names or IDs in chat. - -**Checkpoints this step drives (run each in isolation):** - -| Step | Checkpoint | Gate | -|------|-----------|------| -| DA3.1 | `WD-API-CLIENT-001` — Workday API client registered (SAML ****** grant, functional areas, Include Workday Owned Scope = Yes) | attest | -| DA3.2 | `WD-API-CLIENT-001` — Workday connection fields captured with the registered API client | attest | -| DA3.3 | `WD-TENANT-001` — signed-in employee SAML authentication policy verified | attest | -| DA3.4 | `WD-CONN-102` *(reuse)* — Workday X.509 signing cert matches the Entra one | manual/attest | - -Run any one with: +# DA-3 — Complete the Workday Administrator Handoff -``` -python scripts/flightcheck/cli.py --checkpoint -``` +Role: **Workday Administrator**. This phase covers the Workday-side changes +required by the signed-in employee connection: -**After every checkpoint run, show its result in chat first.** As soon as a -`--checkpoint` run returns, render the result to the user per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -compact result table and, for any `MANUAL` (or `Warning` / `NotConfigured`) row, -its full verification steps — **before** you show any later **Message** or ask any -attestation question. Single-checkpoint runs never open the HTML report, so this -in-chat render is the only place the user sees the manual steps; never ask a user -to attest to steps they have not been shown. - -Both `WD-API-CLIENT-001` and `WD-TENANT-001` are always `MANUAL` — they read only -`.local/connect/workday-da/config.json` and echo the captured values. A `MANUAL` -result is **never** completion: each attest row also needs the user's explicit -acknowledgement (enforced by -[`shared/checklist-updater.md`](shared/checklist-updater.md)). - -**Build order.** These tasks must happen in Workday's natural order, which is -**not** the row-number order: sign-in cert (DA3.0c) → Tenant Setup – Security -(DA3.0d) → **register the API client (DA3.1 + DA3.2)** → **verify the -signed-in employee authentication policy (DA3.3)**. Each section states which -checklist row(s) it completes. - -**On every resume, always re-run DA3.0 (Workday-admin gate) and DA3.0b -(single-tenant SAML pre-gate) first — both are idempotent/read-only — before -working the first incomplete row.** The SAML pre-gate is a safety check that -must run before any tenant change; skipping it on resume risks silently -overwriting an active federation. After re-running DA3.0 and DA3.0b, skip any row -whose `setupStatus` state is already `done`. +- trust the Microsoft Entra signing certificate; +- configure Tenant Setup – Security; +- register the Workday API client and capture its connection values; and +- confirm an active employee authentication policy allows SAML. ---- +It owns internal rows **DA3.1 through DA3.4**, but presents them to the +customer as one **Workday administrator** milestone. Do not expose internal row +IDs or checkpoint IDs. -## DA3.0 — Workday administrator gate +The skill cannot sign in to the Workday administration interface, cannot +change these settings through a Microsoft API, and must never request or +collect a Workday administrator's password. The administrator performs the +steps using their normal organization-approved Workday account. -Every task in this step is a **manual Workday-tenant change** — the SAML signing -certificate, Tenant Setup – Security, the Workday API client, and the -authentication policy. None is reachable through a Microsoft admin API, and the -person running this kit (the maker) is often **not** a Workday administrator. So -these steps must be performed **together with a Workday administrator**. Before -making any tenant change, confirm one is lined up. +Every **Message** block is exact customer text. Do not add internal variables, +file paths, checkpoint output, or implementation commentary. + +On resume, always repeat the administrator availability and active-federation +safety checks before presenting any unfinished Workday action. Skip individual +actions already supported by current scoped evidence, but present the +remaining actions as one handoff rather than separate lifecycle rows. + +--- -This is the attested gate for **DA3.1** (`GATE_MODE = "attested"`, `STEP_ID = -"DA3.1"`, per [`shared/permission-gate.md`](shared/permission-gate.md)) — Workday -has **no directory the kit can query**, so it is an explicit confirmation, not a -programmatic check. +## DA3.0 — Confirm the administrator is available **Message:** -The next steps change your Workday tenant directly — the SAML signing -certificate, Tenant Setup – Security, the Workday API client, and the -authentication policy. These are Workday-administrator tasks, so they should be -done **together with a Workday administrator** (if that isn't you). Before we -start, please confirm you have a Workday administrator ready to work through these -steps with you. +The next milestone requires a Workday administrator. They will use their normal +Workday access to configure sign-in trust, tenant security, the API client, and +the employee authentication policy. The skill will not sign in to Workday or +ask for their password. **End message.** -Use the `vscode_askQuestions` tool: +Use `vscode_askQuestions`: ```json [ { "header": "Workday administrator", - "question": "Have you looped in a Workday admin to perform the Workday side of configuration?", + "question": "Is a Workday administrator available to complete the Workday-side setup now?", "options": [ - { "label": "Yes, I have", "recommended": true }, - { "label": "No, I have not" } + { "label": "Yes, continue" }, + { "label": "Not yet" } ], "allowFreeformInput": false } ] ``` -**If the user chose "Yes, I have":** -- Set `GATE_RESULT = "pass"` and - `GATE_EVIDENCE = { "method": "attested", "outcome": "pass", "provenance": "user-attestation", "note": "user confirmed a Workday administrator is available to perform DA3.1–DA3.4 with them", "capturedAt": "" }`. -- Carry `GATE_EVIDENCE` forward (recorded when the DA3 rows are updated), and - continue to DA3.0b. - -**If the user chose "No, I have not":** - -**Message:** - -No problem — these steps have to be done with a Workday administrator. Line one up -(or ask whoever holds that role to join you), then come back and run this skill -again. - -**End message.** - -- Set `GATE_RESULT = "stop"` and **halt** — do not continue. - -> An attested `"pass"` records that a Workday administrator was **confirmed -> available**, not directory-proven. It satisfies the *gate*, but it does **not** -> by itself complete any DA3 row — each row still needs its own captured evidence -> and acknowledgement per -> [`shared/checklist-updater.md`](shared/checklist-updater.md). - ---- - -## DA3.0b — Single-tenant SAML pre-gate *(do this before any tenant change)* - -Workday supports exactly **one** active Entra-tenant SAML federation at a time. -Pointing a second Entra tenant at the same Workday tenant silently breaks the -first. Before changing anything, identify and record the **current active SAML -IdP** so a later step never overwrites an unrelated federation. - -**Message:** - -Before I change any Workday security settings, I need to check the tenant's -current SAML sign-on. In Workday, search for and open the **Edit Tenant Setup – -Security** task and find the **SAML Setup** section. Tell me, for the currently -enabled Identity Provider row: the **Issuer** (or IdP name), the **Service -Provider ID**, and the **x509 Certificate** name plus its **Valid From** / -**Valid To** dates (Workday shows no thumbprint). If there is -no active SAML IdP yet, just say **none**. - -**End message.** - -Wait for the user's answer, then record it as the pre-gate evidence -(`SAML_ISSUER`, `SAML_SP_ID`, `SAML_CERT`). - -- **If an IdP is already active AND it is not the Entra app DA-2 provisioned** - (the Issuer / Service Provider ID does not match this tenant's `appIdUri` / - `entraAppId` from `.local/connect/workday-da/config.json`): - - **Message:** - - This Workday tenant already has a **different** SAML identity provider active. - Workday only allows one at a time, and replacing it would break the existing - sign-on for its users. I'm stopping here so nothing is overwritten — please - confirm with whoever owns that federation before continuing, then come back. - - **End message.** - - **Halt.** Do not proceed. - -- **Otherwise** (no active IdP, or the active one is this tenant's own Entra app) - → continue. +If the answer is **Not yet**, pause without changing Workday state. If the +answer is **Yes, continue**, record an attested administrator-availability +gate and continue. --- -## DA3.0c — Upload the X.509 signing certificate & confirm certificate parity *(completes DA3.4)* +## DA3.0b — Protect the active federation -Create the Workday **X.509 Public Key** from the Entra signing certificate DA-2 -activated, then confirm the certificate matches — a mismatch means the wrong -certificate was uploaded and SSO will fail. +Workday supports one active SAML identity provider. Before presenting any +tenant change, require the administrator to classify the current configuration. **Message:** -In Entra, open **Enterprise applications → your Workday app → Single sign-on → -SAML Signing Certificate**, and download the **Certificate (Base64)**. Then in -Workday, run the **Create x509 Public Key** task and paste that certificate. Type -**done** when the key is created. +Before changing Workday sign-in, have the administrator open **Edit Tenant +Setup – Security**, find **SAML Setup**, and choose the description that +applies: -**End message.** - -Wait for the user, then verify the certificate parity against the certificate -DA-2 activated in Entra. - -**Message:** +1. **No Identity Provider is enabled.** +2. **The Microsoft Entra provider intended for this setup is enabled.** Provide + only its **Service Provider ID**. +3. **A different provider is enabled, or the administrator is not sure.** -Now I'll compare the certificate you uploaded in Workday against the one activated -in Entra to make sure they match. +Reply with **1**, **2** plus the Service Provider ID, or **3**. **End message.** -**Verify (WD-CONN-102):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 --connect-config ".local/connect/workday-da/config.json" -``` - -`WD-CONN-102` reports the Entra-side certificate health and returns `MANUAL` for -the Workday-side comparison (the Workday cert field is not API-reachable). +Record the classification and `SAML_SP_ID` when option 2 is selected. Do not +request certificate names or validity dates here, and do not ask a second +question that repeats the classification or Service Provider ID. -If FlightCheck's Microsoft Graph token has expired or the cache was cleared, this -command **opens a browser window for a Graph sign-in** before it returns. That is -expected — do **not** cancel or re-run it while it pauses; it is blocked on the -sign-in, not hung, and continues once you complete it. +- **Option 1** — continue. +- **Option 2 with an exact match to this setup's `appIdUri`** — continue. +- **Option 2 without an exact match, option 3, an unclear answer, or a request + to skip the classification** — pause the standard setup. -**Show the `WD-CONN-102` result in chat first.** It always returns `MANUAL` for -the Workday-side comparison, so render it per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -result table **and** its full verification steps — **before** the -certificate-parity question below. Never ask the user to attest to a comparison -they have not been shown. +For the paused case, show: **Message:** -Workday doesn't display a certificate thumbprint, so we compare another way. -Confirm you uploaded the exact **Certificate (Base64)** from your Workday app in -Entra (Single sign-on → SAML Signing Certificate), and that the **Valid From** / -**Valid To** dates shown on the Workday x509 Public Key match that Entra -certificate's validity dates. Do they match? - -**End message.** - -Use the `vscode_askQuestions` tool: - -```json -[ - { - "header": "Certificate parity", - "question": "Does the uploaded Workday certificate (and its Valid From / Valid To dates) match the Entra signing certificate?", - "options": [ - { "label": "Yes, they match", "recommended": true }, - { "label": "No / not sure" } - ], - "allowFreeformInput": false - } -] -``` - -- **"Yes, they match"** → update **DA3.4** via - [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA3.4"`, `GATE="manual"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true`, - `ROW_EVIDENCE` recording the compared thumbprints and confirmation, and the - carried `GATE_EVIDENCE`. -- **"No / not sure"** → leave DA3.4 `in-progress`; have the user re-upload the - correct Base64 certificate from Entra and re-check. Do not continue to DA3.0d - with a mismatched cert. - ---- - -## DA3.0d — Edit Tenant Setup – Security - -Configure the tenant's security so OAuth and SAML sign-on work. This is captured -as part of the `WD-TENANT-001` attestation (verified at the end of DA3.3). - -**Message:** +This Workday tenant may already use a different SAML federation. Replacing the +active provider can interrupt employee sign-in, so `/connect workday` will not +perform or guide that replacement. -In Workday, run **Edit Tenant Setup – Security**. Set the **Redirect URL** for -the sign-on, and enable both **OAuth 2.0 Clients Enabled** and **SAML**. In the -SAML Setup, confirm the **Service Provider ID** matches your Entra app's -**Identifier (Entity ID)** — they must be identical. Type **done** when saved. +Have the Workday and identity administrators review the existing federation, +capture its rollback configuration, and complete any approved identity-provider +change through your organization's normal change process. After the intended +Microsoft Entra provider is active, run `/connect workday` again and choose +option 2 with its Service Provider ID. **End message.** -Wait for the user, then continue to DA3.1. +Stop without presenting the remaining Workday changes. --- -## DA3.1 + DA3.2 — Register the API client & capture the connection fields - -Register the Workday API client, then capture the connection identifiers the -Workday extension package's connection form needs. - -**Message:** - -In Workday, run the **Register API Client** task with **Client Grant Type = SAML -******. Under **Scope (Functional Areas)** select **Core Payroll**, -**Organizations and Roles**, **Staffing**, and **Time Off and Leave**, and set -**Include Workday Owned Scope = Yes** (this is required for the REST -`/workers/me` call). Save it, then open **View API Client** for the client you -just created. Type **done** when you're on the View API Client screen. +## DA3.1–DA3.4 — Workday administrator work packet -**End message.** +Present the remaining Workday work as one packet. Values in braces come from +the Microsoft Entra phase and current provider state. **Message:** -This setup uses each signed-in employee's Workday identity. It does **not** use -an Integration System User, a RaaS report, or an Integration System Security -Group. The functional areas above define which Workday APIs the client can call; -the employee's existing Workday security determines which employee data those -calls may return. There is no separate domain-to-integration-security-group -mapping step in this setup. - -**End message.** - -Wait for the user. Then **capture and validate the connection fields** using the -shared [`shared/connection-fields.md`](shared/connection-fields.md) (sections -C.1–C.6), passing whatever is already known from -`.local/connect/workday-da/config.json`: +Complete this Workday administrator checklist: -- `OAUTH_CLIENT_ID`, `TOKEN_ENDPOINT` — from the **View API Client** screen. -- `WD_TENANT`, `WD_BASE_URL`, `WD_TOKEN_HOST` — read from - `.local/connect/workday-da/config.json` if already captured, otherwise gathered - here from the Workday tenant URL (the token endpoint on the View API Client - screen has the form `https://{WD_TOKEN_HOST}/ccx/oauth2/{WD_TENANT}/token`). -- `APP_ID_URI` — the Entra `appIdUri` from DA-2. +### 1. Trust the Microsoft Entra signing certificate -`shared/connection-fields.md` derives the **SOAP base URL** from the Workday web -host (with a user-prompt fallback), trims the **REST base URL** to `/api`, and -persists `oauthClientId`, `tokenEndpoint`, `soapBaseUrl`, `restBaseUrl`, and -`appIdUri` back to `.local/connect/workday-da/config.json` (round-trip merge — -never drop fields owned by other steps). +1. In Microsoft Entra, open the selected Workday enterprise application. +2. Go to **Single sign-on → SAML Signing Certificate** and download + **Certificate (Base64)**. +3. In Workday, run **Create x509 Public Key** and create the key from that + certificate. +4. Confirm the Workday key's **Valid From** and **Valid To** dates match the + certificate in Microsoft Entra. -**Message:** - -Now I'll confirm the Workday API client you registered was captured correctly. +### 2. Configure Tenant Setup – Security -**End message.** +In **Edit Tenant Setup – Security**: -**Verify (WD-API-CLIENT-001):** +1. Configure the sign-on redirect using the selected Workday application's + Microsoft Entra SAML sign-in values. +2. Enable **OAuth 2.0 Clients Enabled**. +3. Enable **SAML**. +4. Confirm the SAML **Service Provider ID** is exactly `{APP_ID_URI}`. +5. Save the changes. -``` -python scripts/flightcheck/cli.py --checkpoint WD-API-CLIENT-001 --connect-config ".local/connect/workday-da/config.json" -``` +### 3. Register the Workday API client -This echoes the captured `oauthClientId` / `tokenEndpoint` and restates the -registration facts to confirm. `WD-API-CLIENT-001` always returns `MANUAL`, so -render its result in chat per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -result table **and** its full verification steps — **before** you ask the user -to acknowledge the row. Then: - -- Confirm the row via [`shared/checklist-updater.md`](shared/checklist-updater.md) - with `STEP_ID="DA3.1"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, - `ACK=true` once the user acknowledges the client is registered correctly, - plus `ROW_EVIDENCE` recording the confirmed registration facts and the - carried `GATE_EVIDENCE`. -- Then update **DA3.2** (connection fields captured) via - [`shared/checklist-updater.md`](shared/checklist-updater.md) with - `STEP_ID="DA3.2"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true` — - using the persisted fields as `ROW_EVIDENCE` and carrying `GATE_EVIDENCE`. - -If the user says the client is wrong or fields are missing, leave DA3.1/DA3.2 -`in-progress` and re-capture before continuing. +Run **Register API Client** with: ---- +- **Client Grant Type:** SAML ****** +- **Scope (Functional Areas):** Core Payroll, Organizations and Roles, + Staffing, and Time Off and Leave +- **Include Workday Owned Scope:** Yes -## DA3.3 — Verify the signed-in employee authentication policy +Save the client, open **View API Client**, and keep its **Client ID** and +**Token Endpoint** available. -Verify that the Workday environment permits SAML authentication for the intended -employee population. Workday tenants vary in how authentication policies are -organized, and the policy screens may not expose an OAuth-client condition. -Never invent one, never route this signed-in employee setup through an ISU rule, -and never enable a disabled policy only to satisfy this checklist. +This setup uses the signed-in employee's Workday identity. It does not require +an Integration System User, RaaS report, or Integration System Security Group. -**Message:** +### 4. Confirm employee SAML access -In Workday, open **Manage Authentication Policies** for the environment your -employees use. With your Workday administrator, verify that an active rule allows +Open **Manage Authentication Policies** and verify an active rule allows **SAML** for the intended employee population. -- Do not use an ISU or integration-system security-group rule for this setup. -- Do not look for an OAuth-client restriction if this tenant's policy screen - does not provide one. -- Preserve administrator access, employee coverage, and existing network/IP - restrictions. -- If the current active policy already allows employee SAML sign-in, no change - is needed. -- If a change is required, review all pending authentication-policy changes - before activating them. +- Preserve administrator access and existing network or IP restrictions. +- Do not use an ISU or integration-system security-group rule. +- If the active policy already permits employee SAML, do not change it. +- If a change is necessary, review all pending policy changes before + activation. + +Return here when the checklist is complete. **End message.** -Use the `vscode_askQuestions` tool: +Use one structured `vscode_askQuestions` form: ```json [ + { + "header": "Signing certificate", + "question": "Does the Workday x509 key use the exact Microsoft Entra Certificate (Base64), with matching Valid From and Valid To dates?", + "options": [ + { "label": "Yes, they match" }, + { "label": "Not complete or not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "Tenant security", + "question": "Were Tenant Setup – Security changes saved with SAML, OAuth 2.0 clients, and the matching Service Provider ID?", + "options": [ + { "label": "Yes, saved and verified" }, + { "label": "Not complete or not sure" } + ], + "allowFreeformInput": false + }, + { + "header": "API client ID", + "question": "Enter the Workday API Client ID shown on View API Client.", + "allowFreeformInput": true + }, + { + "header": "Token endpoint", + "question": "Enter the Token Endpoint shown on View API Client.", + "allowFreeformInput": true + }, { "header": "Employee SAML policy", - "question": "What did the Workday administrator confirm for the employee authentication policy?", + "question": "What did the Workday administrator confirm?", "options": [ { "label": "Existing active policy already allows employee SAML", - "description": "No policy change or activation was needed", - "recommended": true + "description": "No policy activation was required" }, { "label": "Reviewed policy change was activated", - "description": "The admin preserved employee/admin access and existing network restrictions" + "description": "Employee and administrator access were preserved" }, { - "label": "Not confirmed yet", - "description": "Keep this step in progress" + "label": "Not complete or not sure" } ], "allowFreeformInput": false @@ -409,55 +223,50 @@ Use the `vscode_askQuestions` tool: ] ``` -For either confirmed option, capture the selected policy name or rule and whether -the existing configuration was reused or a reviewed change was activated. For -**Not confirmed yet**, leave DA3.3 `in-progress` and stop without blocking or -resetting completed rows. - -Then verify the whole tenant configuration. - -**Message:** - -Now I'll confirm your Workday tenant security and authentication-policy settings -are in place. - -**End message.** - -**Verify (WD-TENANT-001):** - -``` -python scripts/flightcheck/cli.py --checkpoint WD-TENANT-001 --connect-config ".local/connect/workday-da/config.json" -``` - -This echoes the captured `tenant` / `restBaseUrl` / `soapBaseUrl` / `appIdUri` -and restates the Tenant Setup – Security and signed-in employee -authentication-policy facts to confirm. -`WD-TENANT-001` always returns `MANUAL`, so render its result in chat per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -result table **and** its full verification steps — **before** you ask the user to -confirm. Then update **DA3.3** via -[`shared/checklist-updater.md`](shared/checklist-updater.md) with -`STEP_ID="DA3.3"`, `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true` once -the user confirms one of the two supported outcomes above. Pass that selected -policy/rule and whether it was reused or activated as `ROW_EVIDENCE`, together -with the carried `GATE_EVIDENCE`. - -The **functional** proof of all of this comes downstream, when the Workday -extension package's Dataverse connection authenticates successfully — not -from any standalone Workday call here. Verifying that connection end-to-end is -outside this skill's current scope; see DA-4 for what is and isn't checked. +If either confirmation is incomplete, either returned value is empty, or the +policy is not confirmed, keep the milestone in progress and show only the +unfinished checklist section on resume. + +Validate and persist the connection values through +[`shared/connection-fields.md`](shared/connection-fields.md), sections C.1–C.6. +Pass the returned API client ID and token endpoint plus any previously captured +tenant and URL values. That helper validates the Workday URL shapes, derives +the SOAP and REST base URLs, requests only genuinely missing non-secret +values, and round-trip merges the provider config. + +Do not run manual-only FlightCheck checkpoints merely to echo these values. +Record completion directly through +[`shared/checklist-updater.md`](shared/checklist-updater.md): + +- **DA3.1** — `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true`; + evidence records the API client grant type, functional areas, and Workday + owned scope confirmation. +- **DA3.2** — `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true`; + evidence records the validated client ID, token endpoint, tenant, REST base + URL, and SOAP base URL. +- **DA3.3** — `GATE="attest"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true`; + evidence records whether the existing policy was reused or a reviewed change + was activated. +- **DA3.4** — `GATE="manual"`, `CHECKPOINT_RESULT="MANUAL"`, `ACK=true`; + evidence records that the exact Entra Base64 certificate was used and its + validity dates matched. + +Use `RESULT_SOURCE="user-acknowledgement"` for all four updates and carry the +administrator-availability and federation-match gate evidence. Never store a +Workday password, employee identifier, certificate body, or returned employee +data. --- ## Done -When DA3.1–DA3.4 are all `done`, return control to the orchestrator (`SKILL.md`) -to resume at the next unverified row. +When all four internal rows are complete, show: **Message:** -Your Workday tenant is configured — the signing certificate, Tenant Security, the -API client, and signed-in employee authentication policy are all set. Next I'll -review your Workday connection and let you know what's left. +The Workday administrator milestone is complete. The certificate, tenant +security, API client, connection values, and employee SAML policy are ready. **End message.** + +Return to the orchestrator. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md index 8722bcbff..a9490e93d 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -21,8 +21,42 @@ Resolve the target environment automatically: `sidecarDataverseEndpoint`. 3. If neither exists, show the environments available to the signed-in account and ask the maker to choose one. Do not ask them to type or - copy a URL when a selectable environment is available. Persist the selected - Dataverse URL as `sidecarDataverseEndpoint`. + copy a URL when a selectable environment is available. + +Before persisting the selection, confirm that the selected environment record +has a non-empty Dataverse organization URL (`instanceUrl` or its equivalent). +An environment record with no Dataverse organization URL exists in Power +Platform but does not have a Dataverse database. Do not substitute the +AgentBuilder or Power Platform API endpoint, do not persist an empty value, and +do not run the package checkpoint or installer. + +When the selected environment has no Dataverse database, show: + +**Message:** + +This Power Platform environment doesn't have a Dataverse database yet. +Workday needs Dataverse to host its solution, connection references, and cloud +flows. + +1. Open the [Power Platform admin + center](https://admin.powerplatform.microsoft.com/). +2. Select this environment. +3. Choose **Add Dataverse** or **Add database**, then complete the database + setup. +4. Wait until the Dataverse environment URL is available. +5. Return here and tell me it's ready. I'll refresh the environment and verify + it before continuing. + +If the add-database action isn't available to you, ask a Power Platform +administrator to complete it. Don't create Workday connections or install the +Workday package until this check passes. + +**End message.** + +Keep DA1.1 `in-progress` and stop. After the maker confirms, refresh the +environment inventory rather than trusting acknowledgement alone. Continue +only when the selected environment now exposes a Dataverse organization URL, +then persist that URL as `sidecarDataverseEndpoint`. Call the resolved value `WORKDAY_DATAVERSE_URL`. Never copy it into `.local/config.json`; that file's native `powerPlatformApiEndpoint` remains the @@ -72,16 +106,24 @@ environment is outside this lifecycle and must not affect DA1.1. Continue to **P1.1**. - Any other **`FAILED`** result → show the result and stop. Do not guess whether installation is safe from an unrecognized failure reason. -- **`WARNING` / `SKIPPED`** (Dataverse verification could not run, e.g. - authentication, permissions, endpoint initialization, or a transient error) - → show the result verbatim, keep DA1.1 `in-progress`, and stop; ask the user - to resolve the underlying issue and re-run this step. Never attempt package - installation from an inconclusive result. +- **`WARNING` / `SKIPPED`** (Dataverse verification could not run): + - If the result says the Dataverse environment URL is unavailable, show the + no-Dataverse message and provisioning steps above. + - For authentication, permissions, endpoint initialization, or a transient + error, show the result and its remediation verbatim. + - Keep DA1.1 `in-progress` and stop. Re-run the checkpoint after the maker + resolves the reported issue. Never attempt package installation from an + inconclusive result. --- ## P1.1 — Attempt an automated install +If no matching PAC CLI profile exists, the installer requests one device-code +sign-in. This is separate from the Dataverse browser session because PAC CLI +uses its own credential store. Tell the maker to use the same Environment Maker +account; repeat the sign-in URL and one-time code as copyable chat text. + ``` python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" --ring "{RING}" ``` diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md index 7c4bfb75a..cc659321a 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -42,25 +42,25 @@ Run any one with: python scripts/flightcheck/cli.py --checkpoint ``` -**After every checkpoint run, show its result in chat first.** As soon as a -`--checkpoint` run returns, render the result to the user per -[`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a — the -compact result table and, for any `MANUAL` (or `Warning` / `NotConfigured`) row, -its full verification steps — **before** you show any later **Message** or ask any -attestation question. Single-checkpoint runs never open the HTML report, so this -in-chat render is the only place the user sees the manual steps; never ask a user -to attest to steps they have not been shown. +**After every checkpoint run, surface the customer-relevant result first.** +Follow [`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0–U.0a: +show one concise verification sentence when everything passed, and show the +result table plus full instructions for any `MANUAL`, `Warning`, +`NotConfigured`, or failed outcome before a later message or attestation. +Single-checkpoint runs never open the HTML report, so manual instructions must +still appear in chat. **Build order (row order now matches it).** Row **DA2.1** — the SSO gallery app — is the foundation every other row configures, so it is built first and the rows are numbered in build order (DA2.1 → DA2.7). Each section below is titled by the checklist row it completes. **On every resume, always re-run DA2.0 (role gate), -DA2.0b (Workday tenant URL) and DA2.1 (ensure the app exists) first — all -idempotent — before working the first incomplete row.** This is required, not -cosmetic: DA2.2–DA2.4 configure the app through the in-memory +DA2.0b (Workday tenant URL), DA2.0c (one scoped change approval), and DA2.1 +(ensure the app exists) first — all idempotent except the explicit approval — +before working the first incomplete row.** This is required, not cosmetic: +DA2.2–DA2.4 configure the app through the in-memory `WD_ENTRA_APP_OBJECT_ID` that only DA2.1 populates, so entering directly at a later row after a resume would leave it undefined. After re-running DA2.0, DA2.0b -and DA2.1, skip any row whose `setupStatus` state is already `done`. +through DA2.1, skip any row whose `setupStatus` state is already `done`. --- @@ -84,9 +84,32 @@ canonical tenant selected during `/setup`: az login --tenant "{SETUP_TENANT_ID}" --use-device-code --allow-no-subscriptions ``` + Run this interactively without hiding its output. Read the emitted device + sign-in URL and one-time code, then repeat both in chat as copyable text: + + **Message:** + + Sign in to Microsoft Entra: + + 1. Open `{DEVICE_LOGIN_URL}` + 2. Enter code **`{DEVICE_CODE}`** + 3. Sign in with the administrator account for the selected tenant + + I will continue only after the CLI confirms the sign-in. Do not paste any + password or token into chat. + + **End message.** + + Never require the user to copy a URL or code from the inline terminal. + 4. Re-run `az account show --query tenantId -o tsv`. If it still differs, halt - before running the role query or any `az ad` / Graph mutation. Persist - `tenantId = SETUP_TENANT_ID` to + before running the role query or any `az ad` / Graph mutation. +5. Read the verified Azure CLI account with + `az account show --query user.name -o tsv` and save it as + `ENTRA_ADMIN_ACCOUNT` for this run. It is a non-secret account hint; do not + display it unless the user asks which account is active. Stop if it is empty. + Persist `tenantId = SETUP_TENANT_ID` and + `entraAdminAccount = ENTRA_ADMIN_ACCOUNT` to `.local/connect/workday-da/config.json` only after this verification. Apply the shared [`shared/permission-gate.md`](shared/permission-gate.md) before @@ -183,6 +206,44 @@ identify the app by display name and ask you to choose if more than one matches. --- +## DA2.0c — Approve the Microsoft Entra change plan + +Before the first incomplete DA2 mutation, show one scoped plan covering all +remaining Microsoft Entra work. Do not ask for separate approval before each +Graph or Azure CLI operation. + +**Message:** + +I am ready to configure the selected Workday application in Microsoft Entra. +The remaining plan may: + +- create or reuse the Workday enterprise application; +- configure its SAML identifier, reply URL, and signing certificate; +- expose the Workday connector permission and required Microsoft Graph + permissions; +- grant or verify administrator consent; +- configure user assignment and the signed-in employee identifier; and +- verify the application belongs to the selected Microsoft Entra tenant. + +I will apply changes only to the selected tenant and the exact Workday +application discovered or created during this plan, verify every supported +change after it is made, and stop on an ambiguous target. Continue with this +plan? + +**End message.** + +Use `vscode_askQuestions` with **Continue** and **Cancel** options. Do not +preselect an answer. **Continue** approves the remaining DA2 operations for +the current tenant and selected application during this invocation. **Cancel** +pauses without mutation. + +Show later preview or verification details only when they identify an +unexpected target, a warning, or a failure. Do not request another approval +for a step already covered by this plan. If the resolved tenant or application +changes, discard the approval and return here. + +--- + ## DA2.1 — Instantiate the Workday SSO gallery app *(foundation — do this first)* This creates the single Entra app every other DA2 row configures: the Workday SSO @@ -351,7 +412,7 @@ I'll continue automatically once it finishes. **End message.** ``` -python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 --connect-config ".local/connect/workday-da/config.json" +python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 --connect-config ".local/connect/workday-da/config.json" --preferred-username "{ENTRA_ADMIN_ACCOUNT}" ``` `WD-CONN-102` reports the Entra-side signing-certificate health. It returns @@ -426,7 +487,7 @@ Platform Workday connector is pre-authorized to call it. **Verify (WD-ENTRA-SCOPE-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SCOPE-001 --connect-config ".local/connect/workday-da/config.json" +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SCOPE-001 --connect-config ".local/connect/workday-da/config.json" --preferred-username "{ENTRA_ADMIN_ACCOUNT}" ``` - **`PASSED`** → update **DA2.2** via @@ -473,7 +534,7 @@ permissions. **Verify (WD-ENTRA-CONSENT-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-CONSENT-001 --connect-config ".local/connect/workday-da/config.json" +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-CONSENT-001 --connect-config ".local/connect/workday-da/config.json" --preferred-username "{ENTRA_ADMIN_ACCOUNT}" ``` - **`PASSED`** → update **DA2.3** via @@ -513,7 +574,7 @@ so, that the right users are assigned. **Verify (WD-ASSIGN-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-ASSIGN-001 --connect-config ".local/connect/workday-da/config.json" +python scripts/flightcheck/cli.py --checkpoint WD-ASSIGN-001 --connect-config ".local/connect/workday-da/config.json" --preferred-username "{ENTRA_ADMIN_ACCOUNT}" ``` - **`PASSED`** (assignment satisfied via a group, or not required) → update @@ -578,7 +639,7 @@ your Workday tenant expects. **Verify (WD-ENTRA-NAMEID-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-NAMEID-001 --connect-config ".local/connect/workday-da/config.json" +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-NAMEID-001 --connect-config ".local/connect/workday-da/config.json" --preferred-username "{ENTRA_ADMIN_ACCOUNT}" ``` - **`PASSED`** (a NameID-overriding policy is assigned) → update **DA2.5** via @@ -615,7 +676,7 @@ portal, because the kit can't read the setting directly. cannot read the setting). ``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SIGNOPT-001 --connect-config ".local/connect/workday-da/config.json" +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SIGNOPT-001 --connect-config ".local/connect/workday-da/config.json" --preferred-username "{ENTRA_ADMIN_ACCOUNT}" ``` Present the checkpoint's instructions — its remediation now names the customer's @@ -664,7 +725,7 @@ this phase. **Verify (WD-CONN-010):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-CONN-010 --connect-config ".local/connect/workday-da/config.json" +python scripts/flightcheck/cli.py --checkpoint WD-CONN-010 --connect-config ".local/connect/workday-da/config.json" --preferred-username "{ENTRA_ADMIN_ACCOUNT}" ``` `WD-CONN-010` summarizes the federated Workday SAML app(s) and their entity IDs. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md index 0b81fedf3..6c4610d61 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md @@ -5,8 +5,13 @@ in the DA master checklist. Centralizing it means each skill records status the same way, and the **MANUAL/attestation rule** below is enforced in exactly one place. +The executable authority for migration, gate transitions, locking, validation, +and persistence is `scripts/workday_da_state.py`. This file defines the user +interaction and evidence inputs supplied to that helper; it never authorizes +direct model edits to either state file. + Forked from the CEA `setup/shared/checklist-updater.md` with DA-scoped state -paths (`.local/setup/workday-da/tasks.md`, `.local/connect/workday-da/config.json`). +paths (`.local/connect/workday-da/tasks.md`, `.local/connect/workday-da/config.json`). The logic is identical — only the persisted files differ — so the two skills can evolve independently. @@ -42,7 +47,7 @@ not narrate tool calls. `user-acknowledgement`, or `external-operation`. **Outputs:** -- The matching checklist item in `.local/setup/workday-da/tasks.md` is updated +- The matching checklist item in `.local/connect/workday-da/tasks.md` is updated in place (checkbox + hidden `status:` field). - The mirror record `setupStatus["{STEP_ID}"]` in `.local/connect/workday-da/config.json` is updated (see `config-schema.md`). @@ -51,10 +56,12 @@ not narrate tool calls. ## Files -- **Working copy (read/write):** `.local/setup/workday-da/tasks.md` — the +- **Working copy (read/write):** `.local/connect/workday-da/tasks.md` — the rendered, human-readable checklist. Rendered on first run from the template `src/skills/setup/workday-da/tasks.md` (the canonical row source). If the - working copy doesn't exist yet, render it from the template before updating. + working copy doesn't exist yet, first migrate the exact legacy + `.local/setup/workday-da/tasks.md` file when present; otherwise render it from + the template before updating. Never maintain both paths. - **Durable mirror:** `setupStatus` in `.local/connect/workday-da/config.json`. The tasks file is the view; `setupStatus` is the source of truth a later step reads to know what's already done. @@ -102,9 +109,14 @@ Read `workspace/flightcheck/results.json` — the run that led here wrote it. It { "results": [ { "checkpoint_id": "...", "description": "...", "status": "..." }, ... ] } ``` -Render a GitHub-flavoured markdown table in chat, **one row per entry** in -`results`, using `description` verbatim for **Check** and `status` verbatim for -**Status**: +If every entry is `Passed`, do not render a result table. Show one concise +sentence that the current action was verified, using the calling playbook's +customer-facing action name. Successful internal checks are evidence, not a +second customer checklist. + +When any entry is not `Passed`, render a GitHub-flavoured markdown table in +chat, **one row per entry** in `results`, using `description` verbatim for +**Check** and `status` verbatim for **Status**: ``` | Check | Status | @@ -117,7 +129,7 @@ Rules: identifier — there is no ID column; `description` is the only label shown. - If a `description` or `status` contains a `|`, escape it as `\|`; collapse any newline to a single space. -- If `results` is empty, render **no** table. +- If `results` is empty, or every result is `Passed`, render **no** table. - This table is **in addition to** the row's own **Message** blocks and the manual verification steps below (see U.0a) — it does not replace or alter them. - Draw the table yourself in chat. Do not mention `results.json`, file paths, or @@ -147,21 +159,38 @@ not just `description`/`status` but also the finding and the how-to: For **every** entry in `results` whose `status` is `Manual` (also `Warning` or `NotConfigured`, when present), render a block in chat — one per entry, in the -order they appear — using `description` as the heading, then `result`, then -`remediation`: +order they appear — using `description` as the heading and separating the +finding from the required action: ``` **** +**What FlightCheck found** + +**What you need to verify** + ``` Rules: -- Copy `result` and `remediation` **verbatim** — keep the numbered/bulleted steps - and every line break. Do **not** summarise, shorten, re-order, or paraphrase the - steps; the operator follows them exactly. +- Preserve every word, URL, command, warning, and ordering dependency from + `result` and `remediation`. Do **not** summarise, shorten, re-order, or + paraphrase the content. +- Apply presentation-only Markdown formatting so instructions are not rendered + as dense parallel prose: + - Preserve existing paragraphs and line breaks. + - Render lines beginning with `Step ` as bold subheadings. + - Render existing alphabetic or numeric action markers as list items. + - When a single remediation paragraph contains inline `(1)`, `(2)`, and later + ordered markers, split only at those markers and render the unchanged text + as a numbered list. + - Put standalone commands on their own indented or fenced line. Never alter a + command while formatting it. +- Keep each action group to at most seven visible items. When more detail + exists, first show the current action group, wait for its completion, and + then show the next group; do not omit any instruction. - Still **never** surface `checkpoint_id`, the Step ID, or the hidden comment. - Do **not** open, mention, or link `report.html` — the steps live in chat now. - If no entry has a `Manual`/`Warning`/`NotConfigured` status, render no block. @@ -171,9 +200,9 @@ Rules: ## U.1 — Locate the item -Read `.local/setup/workday-da/tasks.md` (render from the template first if -absent). Find the checklist item whose hidden comment has `id:` equal to -`STEP_ID`. +Read `.local/connect/workday-da/tasks.md` (migrate the legacy path or render +from the template first if absent). Find the checklist item whose hidden +comment has `id:` equal to `STEP_ID`. - If no such item exists, **stop and report** — a skill must not invent items. The canonical item set lives in the checklist template @@ -184,10 +213,12 @@ absent). Find the checklist item whose hidden comment has `id:` equal to --- -## U.2 — Determine the new Status (the MANUAL/attestation rule) +## U.2 — Gather deterministic transition inputs This is the load-bearing rule. **A `MANUAL` or attestation-gated row is never -auto-completed by a flightcheck pass.** +auto-completed by a flightcheck pass.** Gather the checkpoint result, +acknowledgement, row evidence, and gate evidence described below. The state +helper applies the table; do not reproduce the transition by editing files. First apply failure precedence: for every non-advisory row, `CHECKPOINT_RESULT = FAILED` or `ERROR` always produces `blocked`, regardless @@ -225,83 +256,62 @@ If the row is `manual`/`attest` and `ACK` is `false`, before leaving the row manual verification steps — U.0a — for this row's checkpoint must already have been rendered in chat. If they were not, show them now, then ask.) +Use the visible checklist title found in U.1. The confirmation must name that +specific action; never ask the generic question "Have you completed this step?" +and never preselect or recommend a successful answer. + ```json [ { - "header": "Confirm step", - "question": "Have you completed this step and is the evidence captured?", + "header": "Confirm {VISIBLE_TITLE}", + "question": "Did you complete and verify “{VISIBLE_TITLE}” using the requirements shown above?", "options": [ - { "label": "Yes, it's done", "recommended": true }, - { "label": "Not yet" } + { "label": "Completed and verified" }, + { "label": "Not yet or unsure" } ], "allowFreeformInput": false } ] ``` -Only treat the row as acknowledged (`ACK = true`) on an explicit "Yes, it's -done". Never infer acknowledgement from a flightcheck pass. +Only treat the row as acknowledged (`ACK = true`) on an explicit **Completed +and verified** answer after row-specific evidence has been captured. Never +infer acknowledgement from a FlightCheck pass, a bare `done`, or an answer to +a different row. --- -## U.3 — Write the item + mirror - -**Persist immediately — never batch.** Write **both** files below **now**, as part -of this call, before returning control to the caller and before the caller proceeds -to its next row. A completed row must be durable the instant its checkpoint passes, -so that if a later row in the same skill errors, the progress already made is not -lost — the orchestrator resumes from the first non-`done` row in `setupStatus`. - -1. Update the located item in `.local/setup/workday-da/tasks.md` to the state - from U.2: - - Set the checkbox marker: `- [x]` when the resulting status is `done`, - otherwise `- [ ]`. - - Set the hidden `status:` field in that item's comment to the full value - (`pending` / `in-progress` / `done` / `blocked`). - - Leave the visible title/description and every other item untouched. Do not add - any Step ID, checkpoint ID, or status text to the visible line — the checkbox is - the only at-a-glance marker the user sees. -2. Update the mirror in `.local/connect/workday-da/config.json`: - ```json - { - "setupStatus": { - "{STEP_ID}": { - "state": "", - "checkpoint": "", - "gate": "", - "verifiedBy": "", - "evidence": { - "outcome": "", - "provenance": "", - "note": "", - "capturedAt": "" - }, - "gateEvidence": { - "method": "", - "outcome": "", - "provenance": "", - "note": "", - "capturedAt": "" - } - } - } - } - ``` - Set scalar `verifiedBy` from the resulting completed state: - - `programmatic` for a completed `prog` row, - - `attested` for a completed `manual`/`attest` row, - - `reviewed` for a completed `advisory` row, - - `null` for any row that is not `done`. - - Persist `ROW_EVIDENCE` as `evidence` and `GATE_EVIDENCE` as - `gateEvidence` when supplied. Merge these fields with the existing row; - never replace `verifiedBy` with an object. When a row regresses to - `in-progress` or `blocked`, clear stale completion `verifiedBy` and - `evidence`, while retaining current failure evidence and any still-valid - `gateEvidence`. - Merge — do not drop other `setupStatus` keys (round-trip contract in - `config-schema.md`). +## U.3 — Persist through the state helper + +**Persist immediately — never batch.** Invoke the deterministic helper now, +before returning to the caller or proceeding to another row: + +```powershell +python scripts/workday_da_state.py --root . update-row ` + --step-id "" ` + --checkpoint-result "" ` + --result-source "" ` + [--ack] ` + [--evidence-json ''] ` + [--gate-evidence-json ''] +``` + +Omit `--checkpoint-result` only when the row has no checkpoint result. Include +`--ack` only after the explicit acknowledgement in U.2. Pass safe structured +evidence exactly as gathered; never include credentials, tokens, or raw +Workday employee data. + +The helper validates the row against `workday-da.definition.json`, enforces the +gate table, serializes concurrent writers, atomically commits authoritative +`config.json`, derives the checklist view, preserves unrelated fields, and +regresses dependent completion when a previously completed prerequisite no +longer passes. Completed programmatic, attested, and advisory rows are recorded +as `programmatic`, `attested`, and `reviewed`, respectively. If it reports that +config was saved but the checklist could not be refreshed, run: + +```powershell +python scripts/workday_da_state.py --root . reconcile +``` Return control to the calling file. Do not announce file paths or internal mechanics to the user. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md index 2df032d58..4e3328d94 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -7,6 +7,13 @@ cite this file so they agree on field names, owners, and types. **Canonical data file:** `.local/connect/workday-da/config.json` +The executable persisted-state contract is +[`../workday-da.state.schema.json`](../workday-da.state.schema.json). New or +migrated state records `definitionVersion` and `stateSchemaVersion` from +[`../workday-da.definition.json`](../workday-da.definition.json). Legacy files +without those fields are migrated by the deterministic state helper before +schema validation; they are not discarded or treated as fresh setup. + Forked from the CEA `setup/shared/config-schema.md`. The field shapes are the same; only the file path and the owning steps differ — DA has five steps (DA-1 install, DA-2 Entra, DA-3 tenant, DA-4 Power Platform integration, @@ -42,6 +49,7 @@ read by later steps. Unknown/absent fields are treated as `null`. | Field | Type | Owner | Notes | |-------|------|-------|-------| | `sidecarDataverseEndpoint` | string | DA-1 | HTTPS Dataverse organization URL hosting the Workday solution, connections, and flows for a native MOS/AgentBuilder agent. Do not copy it into foundation config. | +| `entraAdminAccount` | string | DA-2 | Non-secret sign-in name verified from the active Azure CLI tenant. Used only as an exact account hint so later Graph checkpoints reuse the correct cached account without another account-selection prompt. | | `baseUrl` | string | DA-2/DA-3 | Workday web host base URL (e.g. `https://wd2-impl.workday.com`). Captured early by DA-2 when the operator has the URL, else by DA-3. | | `tenant` | string | DA-2/DA-3 | Workday tenant short name. Captured early by DA-2 to pin the Entra app deterministically, else by DA-3. | | `tokenHost` | string | DA-2/DA-3 | Services host used to build token / REST URLs. Derived by DA-2 when the URL matches a known pattern, else by DA-3. | @@ -72,7 +80,7 @@ read by later steps. Unknown/absent fields are treated as `null`. Each step records its own checkpoint outcomes under a `setupStatus` object, keyed by **Step ID** (`DA1.1` … `DA5.1`) from the DA master checklist. This is the durable record `shared/checklist-updater.md` reads and writes; the -rendered `.local/setup/workday-da/tasks.md` is the human-readable view of the +rendered `.local/connect/workday-da/tasks.md` is the human-readable view of the same data. ```json @@ -124,9 +132,10 @@ same data. ## Power Platform integration state DA-4 records programmatic evidence for solution-reference binding and supported -flow activation. Agent connection sharing, topic selection, and firewall -allowlisting remain manual or attested until reliable DA-scoped APIs are -available. It must not reuse CEA checkpoints as proof. DA4.6 uses programmatic +flow activation. Agent connection sharing and topic selection remain manual +until reliable DA-scoped APIs are available. Network restrictions are a +non-blocking advisory unless runtime validation identifies a concrete access +failure. DA-4 must not reuse CEA checkpoints as proof. DA4.6 uses programmatic evidence from the checked-in authorization script. --- diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md index ad02a0a40..6b911b1c7 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md @@ -46,7 +46,7 @@ directory: | Power Platform Admin | `programmatic` | Power Platform admin API | | Dataverse maker / system roles | `programmatic` | Dataverse security-role query | | **Workday Administrator** | `attested` | No directory here → explicit named-role attestation + captured evidence | -| **InfoSec / IT** (firewall allowlisting) | `attested` | No directory here → explicit named-role attestation + captured evidence | +| **InfoSec / IT** (conditional network remediation) | Not a setup gate | Involve only when organizational controls or runtime validation identify a Workday host restriction | The calling file picks `GATE_MODE` from this table. **Never** silently pass an attested role — always require the explicit confirmation in section G.2. @@ -132,7 +132,7 @@ Use the `vscode_askQuestions` tool: "header": "Confirm role", "question": "Do you have the {REQUIRED_ROLE} role to perform this step?", "options": [ - { "label": "Yes, I have this role", "recommended": true }, + { "label": "Yes, I have this role" }, { "label": "No / not sure" } ], "allowFreeformInput": false diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md index 57c5894ff..87fb72987 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md @@ -3,8 +3,11 @@ The single, trackable checklist spanning the five Workday connect steps for the **ESS HR agent**. This file is the -**canonical row source**: on first run the skill renders it to the working copy -`.local/setup/workday-da/tasks.md` and then updates **only its own items** +human-readable template for the versioned machine contract in +[`workday-da.definition.json`](workday-da.definition.json). Contract tests keep +the visible rows and hidden metadata synchronized. On first run the skill +renders this template to the working copy +`.local/connect/workday-da/tasks.md` and then updates **only its own items** through the shared [`shared/checklist-updater.md`](shared/checklist-updater.md). The durable mirror of each item's status is `setupStatus` in @@ -92,13 +95,13 @@ express; all items start `pending`. - [ ] **Turn on the Workday cloud flows** — Enable every Workday runtime flow after its connections are bound. - [ ] **Connect Workday to the agent** — Connect each Workday flow in Copilot Studio and allow it to share the connection parameters used for signed-in employee access. - + - [ ] **Authorize the agent to use the Workday flows** — Preview and apply the delegated authorization and workflow sharing required by the ESS HR agent. - [ ] **Configure employee context and topics** — Use the Workday package's V2 signed-in-user context and enable the Workday topics selected for this agent. - -- [ ] **Allow Workday through the firewall** — Allow the Workday REST and SOAP hosts used by the Power Platform managed connectors. - + +- [ ] **Review network restrictions** — Review the Workday REST and SOAP hosts only when organizational network controls restrict managed-connector access. + ### 5. Validate Workday readiness diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md index 659dbd76d..d6727fdb7 100644 --- a/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -60,29 +60,59 @@ The configuration checklist is complete. Now validate the actual employee path: 1. Publish the ESS HR agent. -2. Use a test employee who is assigned to the Workday Entra application and - has valid Workday access. -3. Start a new conversation so stale user-flow state is not reused. -4. Run one enabled Workday scenario, such as checking a vacation balance. -5. Confirm the agent identifies the signed-in employee and returns real - Workday data without asking for another unexpected sign-in. +2. Share the published agent with a test employee who is not the maker who + created the Workday connection. +3. Confirm that employee is assigned to the Workday Entra application and has + valid Workday access. +4. Sign in as that employee and start a new conversation so maker credentials + and stale user-flow state are not reused. +5. Run one enabled, read-only Workday scenario, such as checking a vacation + balance. +6. Confirm the agent identifies the signed-in employee and returns real + Workday data without showing a **Connect**, consent, or additional sign-in + prompt. Did the scenario complete successfully? **End message.** -On success, record the scenario, test user category (never credentials), time, -and result as evidence. First merge provider `status: "ready"` into the -provider config, then update **DA5.1** with `GATE="manual"`, `ACK=true`. This -write order ensures an interruption cannot leave a completed row while the -public readiness signal is missing. Return to the orchestrator. +On success, update **DA5.1** with `GATE="manual"`, `ACK=true` and structured +`ROW_EVIDENCE` in this shape: + +```json +{ + "outcome": "PASSED", + "provenance": "user-acknowledgement", + "note": "Signed-in employee scenario completed with real Workday data.", + "capturedAt": "", + "scenario": { + "scenarioName": "", + "testUserCategory": "non-maker assigned test employee", + "nonMakerTestUserConfirmed": true, + "agentSharedWithTestUser": true, + "signedInUserConfirmed": true, + "realWorkdayDataConfirmed": true, + "connectionPromptObserved": false, + "unexpectedSignIn": false, + "testSurface": "", + "completedAt": "" + } +} +``` + +Never record the employee's name, email, Workday ID, credentials, prompt +contents, or returned Workday data. Do not write provider `status` directly. +The deterministic state helper fingerprints the current agent/environment +scope and derives `status: "ready"` only when every blocking row is done, +revalidation is complete, and this scenario evidence is valid. Return to the +orchestrator. On failure, leave DA5.1 `in-progress`. Run `python scripts/flightcheck/cli.py --scope workdayda --connect-config ".local/connect/workday-da/config.json"` to recheck the environment and DA package. That scope does not prove the live connection, flow authorization, employee-context wiring, or topic execution, -so also revisit the DA4 connection, flow, authorization, topic, and firewall -evidence. If connection parameters recently changed, reconnect the Workday +so also revisit the DA4 connection, flow, authorization, topic, and conditional +network review. If connection parameters recently changed, reconnect the Workday connection and retry with a fresh conversation or test user. --- diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.definition.json b/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.definition.json new file mode 100644 index 000000000..e2c22d9ff --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.definition.json @@ -0,0 +1,705 @@ +{ + "$schema": "./workday-da.definition.schema.json", + "definitionVersion": 1, + "stateSchemaVersion": 1, + "provider": "workday", + "displayName": "Workday", + "supportedVerticals": [ + "hr" + ], + "state": { + "configPath": ".local/connect/workday-da/config.json", + "checklistPath": ".local/connect/workday-da/tasks.md", + "legacyChecklistPaths": [ + ".local/setup/workday-da/tasks.md" + ] + }, + "packages": { + "runtime": { + "applicationName": "msdyn_EssWorkdayRuntime", + "solutionSchemaName": "msdyn_EssWorkdayRuntime", + "flowNames": [ + "ESS Workday Runtime References", + "ESS Workday Runtime REST Execution", + "ESS Workday Runtime" + ], + "versionPolicy": { + "status": "pending-product-confirmation", + "minimumInclusive": null, + "maximumExclusive": null + } + }, + "legacy-da": { + "applicationName": "msdyn_EssDAHRWorkdayHCM", + "solutionSchemaName": "msdyn_EssDAHRWorkday", + "versionPolicy": { + "status": "pending-product-confirmation", + "minimumInclusive": null, + "maximumExclusive": null + } + } + }, + "architectures": [ + { + "id": "native-da", + "agentSchemaNames": [ + "gptagent_copilotforemployeeselfservicehr" + ], + "packageFlavor": "runtime" + }, + { + "id": "classic-da", + "agentSchemaNames": [ + "msdyn_copilotforemployeeselfservicedahr" + ], + "packageFlavor": "legacy-da" + } + ], + "unsupportedAgentSchemaNames": [ + "msdyn_copilotforemployeeselfservicedait", + "gptagent_copilotforemployeeselfserviceit" + ], + "failurePolicy": { + "maxAttempts": 3, + "backoffSeconds": [ + 2, + 5 + ], + "categories": { + "transient": { + "retryable": true + }, + "authentication": { + "retryable": false + }, + "permission": { + "retryable": false + }, + "validation": { + "retryable": false + }, + "unsupported": { + "retryable": false + }, + "conflict": { + "retryable": false + }, + "ambiguous-mutation": { + "retryable": false + }, + "manual-action": { + "retryable": false + }, + "verification-failed": { + "retryable": false + } + } + }, + "completion": { + "providerStatus": "ready", + "finalStepId": "DA5.1", + "requiredStepIds": [ + "DA1.1", + "DA2.1", + "DA2.2", + "DA2.3", + "DA2.4", + "DA2.5", + "DA2.6", + "DA2.7", + "DA3.1", + "DA3.2", + "DA3.3", + "DA3.4", + "DA4.1", + "DA4.2", + "DA4.3", + "DA4.4", + "DA4.5", + "DA4.6", + "DA4.7", + "DA4.8", + "DA5.1" + ] + }, + "customerMilestones": [ + { + "id": "preflight", + "title": "Preflight", + "description": "Verify the selected agent, environment, Dataverse, and Workday extension package.", + "stepIds": [ + "DA1.1" + ] + }, + { + "id": "microsoft-entra", + "title": "Microsoft Entra", + "description": "Configure and verify the Workday sign-in application.", + "stepIds": [ + "DA2.1", + "DA2.2", + "DA2.3", + "DA2.4", + "DA2.5", + "DA2.6", + "DA2.7" + ] + }, + { + "id": "workday-administrator", + "title": "Workday administrator", + "description": "Complete the Workday tenant security, certificate, API client, and employee sign-in configuration.", + "stepIds": [ + "DA3.1", + "DA3.2", + "DA3.3", + "DA3.4" + ] + }, + { + "id": "connections", + "title": "Connections", + "description": "Create the Workday and Microsoft Dataverse connections.", + "stepIds": [ + "DA4.1", + "DA4.2" + ] + }, + { + "id": "runtime-configuration", + "title": "Runtime configuration", + "description": "Bind the connections, activate flows, authorize the agent, and configure employee context and topics.", + "stepIds": [ + "DA4.3", + "DA4.4", + "DA4.5", + "DA4.6", + "DA4.7" + ] + }, + { + "id": "network-readiness", + "title": "Network readiness", + "description": "Review Workday host restrictions only when the organization applies connector or tenant network controls.", + "stepIds": [ + "DA4.8" + ] + }, + { + "id": "employee-validation", + "title": "Employee validation", + "description": "Publish and validate a read-only Workday scenario with a non-maker employee.", + "stepIds": [ + "DA5.1" + ] + } + ], + "steps": [ + { + "id": "DA1.1", + "phase": 1, + "title": "Install the Workday extension package", + "description": "Add the Workday extension package to your ESS HR agent so it can talk to Workday. If the HR base agent isn't installed yet, this step sends you to `/setup` first.", + "role": "Environment Maker", + "owner": "da-1", + "automation": "attempt", + "automationLabel": "Attempt", + "checkpoints": [ + "WD-DA-PKG-001" + ], + "checkpointLabel": "WD-DA-PKG-001", + "gate": "prog", + "gateLabel": "prog, else manual", + "fallbackGate": "manual", + "dependsOn": [], + "scopeFields": [ + "activeAgent", + "sidecarDataverseEndpoint" + ] + }, + { + "id": "DA2.1", + "phase": 2, + "title": "Set up Workday sign-in", + "description": "Create the Microsoft Entra application Workday uses to recognize signed-in employees.", + "role": "App/Cloud App Admin", + "owner": "da-2", + "automation": "yes", + "automationLabel": "Yes", + "checkpoints": [ + "WD-CONN-102" + ], + "checkpointLabel": "WD-CONN-102", + "gate": "manual", + "gateLabel": "manual", + "dependsOn": [ + "DA1.1" + ], + "scopeFields": [ + "tenant", + "tenantId", + "entraAppId", + "entraAppObjectId" + ] + }, + { + "id": "DA2.2", + "phase": 2, + "title": "Allow Power Platform to call Workday", + "description": "Add the permission used by the Workday connector and the Microsoft Graph permissions needed for sign-in.", + "role": "App/Cloud App Admin or App Owner", + "owner": "da-2", + "automation": "yes", + "automationLabel": "Yes", + "checkpoints": [ + "WD-ENTRA-SCOPE-001" + ], + "checkpointLabel": "WD-ENTRA-SCOPE-001", + "gate": "prog", + "gateLabel": "prog", + "dependsOn": [ + "DA2.1" + ], + "scopeFields": [ + "entraAppObjectId", + "appIdUri", + "scopeGuid" + ] + }, + { + "id": "DA2.3", + "phase": 2, + "title": "Approve the sign-in permissions", + "description": "Grant organization-wide consent for the permissions the Workday connection needs.", + "role": "Consent-capable role (App/Cloud App Admin, Priv Role Admin, GA)", + "owner": "da-2", + "automation": "attempt", + "automationLabel": "Attempt", + "checkpoints": [ + "WD-ENTRA-CONSENT-001" + ], + "checkpointLabel": "WD-ENTRA-CONSENT-001", + "gate": "prog", + "gateLabel": "prog; escalate to manual if blocked", + "fallbackGate": "manual", + "dependsOn": [ + "DA2.2" + ], + "scopeFields": [ + "entraAppObjectId", + "tenantId" + ] + }, + { + "id": "DA2.4", + "phase": 2, + "title": "Choose who can use Workday", + "description": "Assign the employees or groups allowed to use the Workday application, or confirm assignment is not required.", + "role": "App/Cloud App Admin", + "owner": "da-2", + "automation": "yes", + "automationLabel": "Yes", + "checkpoints": [ + "WD-ASSIGN-001" + ], + "checkpointLabel": "WD-ASSIGN-001", + "gate": "prog", + "gateLabel": "prog", + "dependsOn": [ + "DA2.1" + ], + "scopeFields": [ + "entraAppObjectId", + "tenantId" + ] + }, + { + "id": "DA2.5", + "phase": 2, + "title": "Match the signed-in employee", + "description": "Configure the sign-in identifier Workday uses to find the current employee.", + "role": "App/Cloud App Admin", + "owner": "da-2", + "automation": "attempt", + "automationLabel": "Attempt", + "checkpoints": [ + "WD-ENTRA-NAMEID-001" + ], + "checkpointLabel": "WD-ENTRA-NAMEID-001", + "gate": "prog", + "gateLabel": "prog; degrade to manual portal row if brittle", + "fallbackGate": "manual", + "dependsOn": [ + "DA2.1" + ], + "scopeFields": [ + "entraAppObjectId", + "tenantId" + ] + }, + { + "id": "DA2.6", + "phase": 2, + "title": "Sign the Workday sign-in response", + "description": "Turn on \"Sign SAML response and assertion\" so Workday trusts the sign-in response.", + "role": "App/Cloud App Admin", + "owner": "da-2", + "automation": "no", + "automationLabel": "No (portal-only)", + "checkpoints": [ + "WD-ENTRA-SIGNOPT-001" + ], + "checkpointLabel": "WD-ENTRA-SIGNOPT-001", + "gate": "manual", + "gateLabel": "manual", + "dependsOn": [ + "DA2.1" + ], + "scopeFields": [ + "entraAppObjectId", + "tenantId" + ] + }, + { + "id": "DA2.7", + "phase": 2, + "title": "Confirm the correct Microsoft Entra tenant", + "description": "Verify Workday is connected to this environment's Microsoft Entra tenant.", + "role": "App/Cloud App Admin", + "owner": "da-2", + "automation": "no", + "automationLabel": "No", + "checkpoints": [ + "WD-CONN-010" + ], + "checkpointLabel": "WD-CONN-010", + "gate": "attest", + "gateLabel": "attest", + "dependsOn": [ + "DA2.1" + ], + "scopeFields": [ + "tenant", + "tenantId", + "entraAppObjectId" + ] + }, + { + "id": "DA3.1", + "phase": 3, + "title": "Register the Workday API client", + "description": "In Workday, register the API client for the agent, including the functional areas and Workday-owned scope.", + "role": "Workday Administrator", + "owner": "da-3", + "automation": "no", + "automationLabel": "No", + "checkpoints": [ + "WD-API-CLIENT-001" + ], + "checkpointLabel": "WD-API-CLIENT-001", + "gate": "attest", + "gateLabel": "attest", + "dependsOn": [ + "DA2.7" + ], + "scopeFields": [ + "tenant", + "oauthClientId", + "tokenEndpoint" + ] + }, + { + "id": "DA3.2", + "phase": 3, + "title": "Capture your Workday connection details", + "description": "Record the client ID, token endpoint, REST and SOAP base URLs, and tenant name needed to connect.", + "role": "Workday Administrator", + "owner": "da-3", + "automation": "no", + "automationLabel": "No", + "checkpoints": [ + "WD-API-CLIENT-001" + ], + "checkpointLabel": "WD-API-CLIENT-001", + "gate": "attest", + "gateLabel": "attest", + "dependsOn": [ + "DA3.1" + ], + "scopeFields": [ + "tenant", + "baseUrl", + "tokenEndpoint", + "restBaseUrl", + "soapBaseUrl", + "oauthClientId" + ] + }, + { + "id": "DA3.3", + "phase": 3, + "title": "Verify employee SAML sign-in policy", + "description": "Confirm an active Workday authentication rule allows SAML for the intended employees, or have the Workday administrator review and activate the required change.", + "role": "Workday Administrator", + "owner": "da-3", + "automation": "no", + "automationLabel": "No", + "checkpoints": [ + "WD-TENANT-001" + ], + "checkpointLabel": "WD-TENANT-001", + "gate": "attest", + "gateLabel": "attest", + "dependsOn": [ + "DA3.2" + ], + "scopeFields": [ + "tenant", + "domainName" + ] + }, + { + "id": "DA3.4", + "phase": 3, + "title": "Match the signing certificate", + "description": "Confirm the Workday-side signing certificate matches the one in Entra (validity dates, or an externally-computed SHA-1 — Workday shows no thumbprint).", + "role": "Workday Administrator", + "owner": "da-3", + "automation": "no", + "automationLabel": "No (Workday cert field not API-reachable)", + "checkpoints": [ + "WD-CONN-102" + ], + "checkpointLabel": "WD-CONN-102", + "gate": "manual", + "gateLabel": "manual/attest (WD-CONN-102 returns MANUAL — operator compares certificate: dates / external SHA-1)", + "dependsOn": [ + "DA2.1" + ], + "scopeFields": [ + "tenant", + "entraAppObjectId" + ] + }, + { + "id": "DA4.1", + "phase": 4, + "title": "Create the Workday connection", + "description": "Create the signed-in employee Workday connection with the captured Workday endpoints.", + "role": "Environment Maker", + "owner": "da-4", + "automation": "no", + "automationLabel": "No", + "checkpoints": [], + "checkpointLabel": "n/a", + "gate": "manual", + "gateLabel": "manual", + "dependsOn": [ + "DA3.2" + ], + "scopeFields": [ + "sidecarDataverseEndpoint", + "tenant", + "oauthClientId", + "restBaseUrl", + "soapBaseUrl" + ] + }, + { + "id": "DA4.2", + "phase": 4, + "title": "Create the Microsoft Dataverse connection", + "description": "Create or select an active Dataverse connection owned by the maker in this environment.", + "role": "Environment Maker", + "owner": "da-4", + "automation": "no", + "automationLabel": "No", + "checkpoints": [], + "checkpointLabel": "n/a", + "gate": "manual", + "gateLabel": "manual", + "dependsOn": [ + "DA1.1" + ], + "scopeFields": [ + "sidecarDataverseEndpoint" + ] + }, + { + "id": "DA4.3", + "phase": 4, + "title": "Bind the extension connections", + "description": "Attach the Workday and Dataverse connections to the installed Workday runtime references.", + "role": "Environment Maker", + "owner": "da-4", + "automation": "yes", + "automationLabel": "Yes", + "checkpoints": [], + "checkpointLabel": "post-write reference verification", + "gate": "prog", + "gateLabel": "prog", + "dependsOn": [ + "DA4.1", + "DA4.2" + ], + "scopeFields": [ + "sidecarDataverseEndpoint" + ] + }, + { + "id": "DA4.4", + "phase": 4, + "title": "Turn on the Workday cloud flows", + "description": "Enable every Workday runtime flow after its connections are bound.", + "role": "Environment Maker", + "owner": "da-4", + "automation": "attempt", + "automationLabel": "Attempt", + "checkpoints": [], + "checkpointLabel": "flow state verification", + "gate": "prog", + "gateLabel": "prog, else manual", + "fallbackGate": "manual", + "dependsOn": [ + "DA4.3" + ], + "scopeFields": [ + "sidecarDataverseEndpoint" + ] + }, + { + "id": "DA4.5", + "phase": 4, + "title": "Connect Workday to the agent", + "description": "Connect each Workday flow in Copilot Studio and allow it to share the connection parameters used for signed-in employee access.", + "role": "Environment Maker", + "owner": "da-4", + "automation": "no", + "automationLabel": "No", + "checkpoints": [ + "WD-DA-CONN-001" + ], + "checkpointLabel": "WD-DA-CONN-001", + "gate": "prog", + "gateLabel": "prog, else manual", + "fallbackGate": "manual", + "dependsOn": [ + "DA4.4" + ], + "scopeFields": [ + "activeAgent", + "sidecarDataverseEndpoint" + ] + }, + { + "id": "DA4.6", + "phase": 4, + "title": "Authorize the agent to use the Workday flows", + "description": "Preview and apply the delegated authorization and workflow sharing required by the ESS HR agent.", + "role": "Power Platform Administrator", + "owner": "da-4", + "automation": "yes", + "automationLabel": "Yes", + "checkpoints": [], + "checkpointLabel": "authorization script verification", + "gate": "prog", + "gateLabel": "prog", + "dependsOn": [ + "DA4.5" + ], + "scopeFields": [ + "activeAgent", + "sidecarDataverseEndpoint" + ] + }, + { + "id": "DA4.7", + "phase": 4, + "title": "Configure employee context and topics", + "description": "Use the Workday package's V2 signed-in-user context and enable the Workday topics selected for this agent.", + "role": "Environment Maker", + "owner": "da-4", + "automation": "attempt", + "automationLabel": "Attempt", + "checkpoints": [ + "WD-DA-CTX-001" + ], + "checkpointLabel": "WD-DA-CTX-001", + "gate": "manual", + "gateLabel": "prog user-context + manual topics", + "dependsOn": [ + "DA4.5" + ], + "scopeFields": [ + "activeAgent", + "sidecarDataverseEndpoint" + ] + }, + { + "id": "DA4.8", + "phase": 4, + "title": "Review network restrictions", + "description": "Review the Workday REST and SOAP hosts only when organizational network controls restrict managed-connector access.", + "role": "InfoSec/IT", + "owner": "da-4", + "automation": "no", + "automationLabel": "No", + "checkpoints": [], + "checkpointLabel": "n/a", + "gate": "advisory", + "gateLabel": "advisory", + "dependsOn": [ + "DA3.2" + ], + "scopeFields": [ + "restBaseUrl", + "soapBaseUrl" + ] + }, + { + "id": "DA5.1", + "phase": 5, + "title": "Validate a signed-in Workday scenario", + "description": "Run a Workday topic as a signed-in employee and confirm the agent returns real data before marking the environment ready.", + "role": "Environment Maker + Workday test user", + "owner": "da-5", + "automation": "no", + "automationLabel": "No", + "checkpoints": [], + "checkpointLabel": "n/a", + "gate": "manual", + "gateLabel": "manual", + "dependsOn": [ + "DA1.1", + "DA2.1", + "DA2.2", + "DA2.3", + "DA2.4", + "DA2.5", + "DA2.6", + "DA2.7", + "DA3.1", + "DA3.2", + "DA3.3", + "DA3.4", + "DA4.1", + "DA4.2", + "DA4.3", + "DA4.4", + "DA4.5", + "DA4.6", + "DA4.7", + "DA4.8" + ], + "scopeFields": [ + "activeAgent", + "sidecarDataverseEndpoint", + "tenant", + "entraAppObjectId", + "oauthClientId", + "restBaseUrl", + "soapBaseUrl" + ] + } + ] +} diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.definition.schema.json b/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.definition.schema.json new file mode 100644 index 000000000..2624be213 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.definition.schema.json @@ -0,0 +1,413 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/microsoft/Employee-Self-Service-Agent-Developer-Kit/workday-da.definition.schema.json", + "title": "Workday DA setup definition", + "type": "object", + "additionalProperties": false, + "required": [ + "definitionVersion", + "stateSchemaVersion", + "provider", + "displayName", + "supportedVerticals", + "state", + "packages", + "architectures", + "unsupportedAgentSchemaNames", + "failurePolicy", + "completion", + "customerMilestones", + "steps" + ], + "properties": { + "$schema": { + "type": "string" + }, + "definitionVersion": { + "type": "integer", + "minimum": 1 + }, + "stateSchemaVersion": { + "type": "integer", + "minimum": 1 + }, + "provider": { + "const": "workday" + }, + "displayName": { + "type": "string", + "minLength": 1 + }, + "supportedVerticals": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + }, + "state": { + "type": "object", + "additionalProperties": false, + "required": [ + "configPath", + "checklistPath", + "legacyChecklistPaths" + ], + "properties": { + "configPath": { + "type": "string", + "minLength": 1 + }, + "checklistPath": { + "type": "string", + "minLength": 1 + }, + "legacyChecklistPaths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, + "packages": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/package" + } + }, + "architectures": { + "type": "array", + "items": { + "$ref": "#/$defs/architecture" + }, + "minItems": 1 + }, + "unsupportedAgentSchemaNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "failurePolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "maxAttempts", + "backoffSeconds", + "categories" + ], + "properties": { + "maxAttempts": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "backoffSeconds": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 60 + }, + "maxItems": 4 + }, + "categories": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "retryable" + ], + "properties": { + "retryable": { + "type": "boolean" + } + } + } + } + } + }, + "completion": { + "type": "object", + "additionalProperties": false, + "required": [ + "providerStatus", + "finalStepId", + "requiredStepIds" + ], + "properties": { + "providerStatus": { + "const": "ready" + }, + "finalStepId": { + "$ref": "#/$defs/stepId" + }, + "requiredStepIds": { + "type": "array", + "items": { + "$ref": "#/$defs/stepId" + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "customerMilestones": { + "type": "array", + "items": { + "$ref": "#/$defs/customerMilestone" + }, + "minItems": 1 + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/step" + }, + "minItems": 1 + } + }, + "$defs": { + "stepId": { + "type": "string", + "pattern": "^DA[1-5]\\.[1-9][0-9]*$" + }, + "gate": { + "type": "string", + "enum": [ + "prog", + "manual", + "attest", + "advisory" + ] + }, + "versionPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "minimumInclusive", + "maximumExclusive" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "pending-product-confirmation", + "enforced" + ] + }, + "minimumInclusive": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "maximumExclusive": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "package": { + "type": "object", + "additionalProperties": false, + "required": [ + "applicationName", + "solutionSchemaName", + "versionPolicy" + ], + "properties": { + "applicationName": { + "type": "string", + "minLength": 1 + }, + "solutionSchemaName": { + "type": "string", + "minLength": 1 + }, + "flowNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + }, + "versionPolicy": { + "$ref": "#/$defs/versionPolicy" + } + } + }, + "architecture": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "agentSchemaNames", + "packageFlavor" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "agentSchemaNames": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + }, + "packageFlavor": { + "type": "string", + "minLength": 1 + } + } + }, + "customerMilestone": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "description", + "stepIds" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "stepIds": { + "type": "array", + "items": { + "$ref": "#/$defs/stepId" + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "phase", + "title", + "description", + "role", + "owner", + "automation", + "automationLabel", + "checkpoints", + "checkpointLabel", + "gate", + "gateLabel", + "dependsOn", + "scopeFields" + ], + "properties": { + "id": { + "$ref": "#/$defs/stepId" + }, + "phase": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "minLength": 1 + }, + "owner": { + "type": "string", + "pattern": "^da-[1-5]$" + }, + "automation": { + "type": "string", + "enum": [ + "yes", + "attempt", + "no" + ] + }, + "automationLabel": { + "type": "string", + "minLength": 1 + }, + "checkpoints": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "checkpointLabel": { + "type": "string", + "minLength": 1 + }, + "gate": { + "$ref": "#/$defs/gate" + }, + "gateLabel": { + "type": "string", + "minLength": 1 + }, + "fallbackGate": { + "$ref": "#/$defs/gate" + }, + "dependsOn": { + "type": "array", + "items": { + "$ref": "#/$defs/stepId" + }, + "uniqueItems": true + }, + "scopeFields": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + } + } +} diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.state.schema.json b/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.state.schema.json new file mode 100644 index 000000000..6c752fa60 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/workday-da.state.schema.json @@ -0,0 +1,277 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/microsoft/Employee-Self-Service-Agent-Developer-Kit/workday-da.state.schema.json", + "title": "Workday DA persisted state", + "type": "object", + "additionalProperties": true, + "required": [ + "definitionVersion", + "stateSchemaVersion", + "status", + "setupStatus" + ], + "properties": { + "definitionVersion": { + "type": "integer", + "minimum": 1 + }, + "stateSchemaVersion": { + "type": "integer", + "minimum": 1 + }, + "status": { + "type": "string", + "enum": [ + "in-progress", + "configured", + "ready" + ] + }, + "vertical": { + "const": "hr" + }, + "verticals": { + "type": "array", + "items": { + "const": "hr" + }, + "minItems": 1, + "maxItems": 1 + }, + "setupStatus": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^DA[1-5]\\.[1-9][0-9]*$": { + "$ref": "#/$defs/stepState" + } + } + }, + "revalidation": { + "type": "object", + "additionalProperties": false, + "required": [ + "requiredStepIds", + "createdAt" + ], + "properties": { + "requiredStepIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^DA[1-5]\\.[1-9][0-9]*$" + }, + "minItems": 1, + "uniqueItems": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + }, + "$defs": { + "evidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "outcome", + "provenance", + "note", + "capturedAt" + ], + "properties": { + "outcome": { + "type": "string", + "minLength": 1 + }, + "provenance": { + "type": "string", + "minLength": 1 + }, + "note": { + "type": "string", + "minLength": 1 + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "scopeFingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "scenario": { + "$ref": "#/$defs/readinessScenario" + }, + "failureCategory": { + "type": "string", + "enum": [ + "transient", + "authentication", + "permission", + "validation", + "unsupported", + "conflict", + "ambiguous-mutation", + "manual-action", + "verification-failed" + ] + }, + "retryable": { + "type": "boolean" + }, + "attemptCount": { + "type": "integer", + "minimum": 1, + "maximum": 5 + } + } + }, + "readinessScenario": { + "type": "object", + "additionalProperties": false, + "required": [ + "scenarioName", + "testUserCategory", + "signedInUserConfirmed", + "realWorkdayDataConfirmed", + "unexpectedSignIn", + "completedAt" + ], + "properties": { + "scenarioName": { + "type": "string", + "minLength": 1 + }, + "testUserCategory": { + "type": "string", + "minLength": 1 + }, + "nonMakerTestUserConfirmed": { + "const": true + }, + "agentSharedWithTestUser": { + "const": true + }, + "signedInUserConfirmed": { + "const": true + }, + "realWorkdayDataConfirmed": { + "const": true + }, + "connectionPromptObserved": { + "const": false + }, + "unexpectedSignIn": { + "const": false + }, + "testSurface": { + "type": "string", + "minLength": 1 + }, + "completedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "gateEvidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "outcome", + "provenance", + "note", + "capturedAt" + ], + "properties": { + "method": { + "type": "string", + "enum": [ + "programmatic", + "attested" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "pass", + "stop" + ] + }, + "provenance": { + "type": "string", + "enum": [ + "role-query", + "user-attestation" + ] + }, + "note": { + "type": "string", + "minLength": 1 + }, + "capturedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "stepState": { + "type": "object", + "additionalProperties": false, + "required": [ + "state", + "checkpoint", + "gate", + "verifiedBy" + ], + "properties": { + "state": { + "type": "string", + "enum": [ + "pending", + "in-progress", + "done", + "blocked" + ] + }, + "checkpoint": { + "type": [ + "string", + "null" + ] + }, + "gate": { + "type": "string", + "enum": [ + "prog", + "manual", + "attest", + "advisory" + ] + }, + "verifiedBy": { + "type": [ + "string", + "null" + ], + "enum": [ + "programmatic", + "attested", + "reviewed", + null + ] + }, + "evidence": { + "$ref": "#/$defs/evidence" + }, + "gateEvidence": { + "$ref": "#/$defs/gateEvidence" + } + } + } + } +} diff --git a/tests/flightcheck/checks/test_da_connection_refs.py b/tests/flightcheck/checks/test_da_connection_refs.py index e3b4c42e7..c647662fc 100644 --- a/tests/flightcheck/checks/test_da_connection_refs.py +++ b/tests/flightcheck/checks/test_da_connection_refs.py @@ -64,6 +64,18 @@ def test_agent_bot_ids_ignores_blank_and_non_string(): assert reader.agent_bot_ids(config) == ["X"] +def test_active_agent_bot_id_resolves_the_selected_multi_agent_entry(): + config = { + "activeAgent": "ess-hr", + "agent": {"slug": "stale-agent", "botId": "STALE-BOT"}, + "agents": [ + {"slug": "ess-it", "botId": "IT-BOT"}, + {"slug": "ess-hr", "botId": "HR-BOT"}, + ], + } + assert reader.active_agent_bot_id(config) == "HR-BOT" + + # -------------------------------------------------------------------------- # read_active_agent_connection_references (DV-CONN-001 surface) # -------------------------------------------------------------------------- @@ -79,6 +91,27 @@ def test_read_active_none_when_no_active_bot_id(): assert reader.read_active_agent_connection_references(runner) is None +def test_read_active_uses_active_agent_in_multi_agent_config(): + payload = ab.components_with_references( + references=[ab.workday_connection_reference(connection_id="wd-conn-1")] + ) + runner = _FakeRunner( + _FakeClient({"HR-BOT": payload}), + { + "activeAgent": "ess-hr", + "agents": [ + {"slug": "ess-it", "botId": "IT-BOT"}, + {"slug": "ess-hr", "botId": "HR-BOT"}, + ], + }, + ) + + rows = reader.read_active_agent_connection_references(runner) + + assert len(rows) == 1 + assert rows[0]["botid"] == "HR-BOT" + + def test_read_active_normalizes_workday_row(): payload = ab.components_with_references( references=[ab.workday_connection_reference(connection_id="wd-conn-1")] diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py index 86acdd41f..cb2c9037f 100644 --- a/tests/flightcheck/checks/test_workday_da.py +++ b/tests/flightcheck/checks/test_workday_da.py @@ -21,15 +21,19 @@ import responses from tests.conftest import FAKE_DATAVERSE_URL, require_validated_mock +from tests.mocks import agentbuilder_connectivity as ab from tests.mocks import dataverse as dv require_validated_mock(dv) +require_validated_mock(ab) # Production module — flightcheck is importable because pyproject.toml puts # solutions/ess-maker-skills/scripts on pythonpath. from flightcheck.checks.workday_da import ( # noqa: E402 _check_workday_da_package_installed, + _check_workday_da_parameter_sharing, + _check_workday_da_user_context, ) @@ -46,6 +50,13 @@ ) SOLUTION_ID = "22222222-2222-2222-2222-222222222222" +BOT_ID = "11111111-2222-3333-4444-555555555555" +TOPIC_SELECT = ( + "botcomponentid,name,schemaname,data,statecode,statuscode" +) +TOPIC_FILTER = ( + f"_parentbotid_value eq '{BOT_ID}' and componenttype eq 9" +) # ─────────────────────────────────────────────────────────────────────── @@ -59,6 +70,7 @@ class _MinimalRunner: dv_token: str | None config: dict[str, Any] = field(default_factory=dict) agent_slug: str | None = None + agentbuilder: Any = None @pytest.fixture @@ -71,6 +83,7 @@ def _select_classic_hr(runner: _MinimalRunner) -> None: "activeAgent": "ess-hr", "agents": [{ "slug": "ess-hr", + "botId": BOT_ID, "schemaName": "msdyn_copilotforemployeeselfservicedahr", }], } @@ -107,6 +120,42 @@ def _register_solutions(solutions: list[dict[str, Any]]) -> None: )) +def _topic_record( + record_id: str, + name: str, + schema_name: str, + data: str, + *, + statecode: int = 0, +) -> dict[str, Any]: + return { + "botcomponentid": record_id, + "name": name, + "schemaname": schema_name, + "data": data, + "statecode": statecode, + "statuscode": 1, + } + + +def _register_topics(topics: list[dict[str, Any]]) -> None: + responses.add(**dv.query( + base_url=BASE_URL, + entity_set="botcomponents", + records=topics, + select=TOPIC_SELECT, + filter_expr=TOPIC_FILTER, + )) + + +class _FakeAgentBuilder: + def __init__(self, payload_by_bot: dict[str, dict[str, Any]]): + self.payload_by_bot = payload_by_bot + + def fetch_components(self, bot_id: str) -> dict[str, Any]: + return self.payload_by_bot.get(bot_id, {}) + + # ─────────────────────────────────────────────────────────────────────── # Tests — one per verdict path. # ─────────────────────────────────────────────────────────────────────── @@ -121,14 +170,19 @@ def test_skipped_when_env_url_missing() -> None: assert r.checkpoint_id == "WD-DA-PKG-001" assert r.category == "Workday DA" assert r.status == "Skipped" - assert "Dataverse URL or access token not available" in r.result + assert "does not expose a Dataverse environment URL" in r.result + assert "Add Dataverse or Add database" in r.remediation + assert "Power Platform administrator" in r.remediation def test_skipped_when_token_missing() -> None: results = _check_workday_da_package_installed( _MinimalRunner(env_url=BASE_URL, dv_token=None) ) - assert results[0].status == "Skipped" + result = results[0] + assert result.status == "Skipped" + assert "Dataverse access token is not available" in result.result + assert "Refresh Dataverse authentication" in result.remediation @responses.activate @@ -261,6 +315,38 @@ def test_passed_when_hr_workday_child_present(runner: _MinimalRunner) -> None: assert r.remediation == "" +@responses.activate +def test_failed_when_installed_package_version_is_missing( + runner: _MinimalRunner, +) -> None: + _select_classic_hr(runner) + solution = _solution_record("msdyn_EssDAHRWorkday") + solution.pop("version") + _register_solutions([solution]) + + result = _check_workday_da_package_installed(runner)[0] + + assert result.status == "Failed" + assert "version cannot be validated" in result.result + assert "four-part numeric solution version" in result.remediation + + +@responses.activate +def test_failed_when_installed_package_version_is_malformed( + runner: _MinimalRunner, +) -> None: + _select_classic_hr(runner) + _register_solutions([ + _solution_record("msdyn_EssDAHRWorkday", version="2.preview"), + ]) + + result = _check_workday_da_package_installed(runner)[0] + + assert result.status == "Failed" + assert "four numeric components" in result.result + assert "Repair or upgrade" in result.remediation + + @responses.activate def test_it_agent_does_not_block_supported_hr_package( runner: _MinimalRunner, @@ -403,3 +489,202 @@ def test_warning_when_dataverse_returns_401(runner: _MinimalRunner) -> None: assert r.status == "Warning" assert "401" in r.result assert "Re-run FlightCheck" in r.remediation + + +def test_parameter_sharing_passes_for_the_active_agent_only( + runner: _MinimalRunner, +) -> None: + shared = ab.shared_connection_parameters_json_string() + runner.config = { + "activeAgent": "ess-hr", + "agents": [ + {"slug": "ess-hr", "botId": "HR-BOT"}, + {"slug": "other-agent", "botId": "OTHER-BOT"}, + ], + } + runner.agentbuilder = _FakeAgentBuilder({ + "HR-BOT": ab.components_with_references(references=[ + ab.workday_connection_reference( + connection_id="wd-connected", + shared_connection_parameters=shared, + ) + ]), + "OTHER-BOT": ab.components_with_references(references=[ + ab.workday_connection_reference( + connection_id="wd-other", + shared_connection_parameters=None, + ) + ]), + }) + + result = _check_workday_da_parameter_sharing(runner)[0] + + assert result.checkpoint_id == "WD-DA-CONN-001" + assert result.status == "Passed" + assert "All 1 connected Workday reference" in result.result + assert result.remediation == "" + + +def test_parameter_sharing_fails_when_active_agent_has_no_shared_values( + runner: _MinimalRunner, +) -> None: + runner.config = { + "activeAgent": "ess-hr", + "agents": [{"slug": "ess-hr", "botId": "HR-BOT"}], + } + runner.agentbuilder = _FakeAgentBuilder({ + "HR-BOT": ab.components_with_references(references=[ + ab.workday_connection_reference( + connection_id="wd-connected", + shared_connection_parameters=None, + ) + ]) + }) + + result = _check_workday_da_parameter_sharing(runner)[0] + + assert result.status == "Failed" + assert "do not contain shared connection parameters" in result.result + assert "Allow permission to share parameters" in result.remediation + + +def test_parameter_sharing_is_not_configured_when_reference_is_unbound( + runner: _MinimalRunner, +) -> None: + runner.config = { + "activeAgent": "ess-hr", + "agents": [{"slug": "ess-hr", "botId": "HR-BOT"}], + } + runner.agentbuilder = _FakeAgentBuilder({ + "HR-BOT": ab.components_with_references(references=[ + ab.workday_connection_reference( + connection_id=None, + shared_connection_parameters=None, + ) + ]) + }) + + result = _check_workday_da_parameter_sharing(runner)[0] + + assert result.status == "NotConfigured" + assert "are not connected" in result.result + assert "connect every Workday flow entry" in result.remediation + + +@responses.activate +def test_user_context_passes_when_redirect_and_target_are_active( + runner: _MinimalRunner, +) -> None: + import configure_workday_da_user_context as user_context + + runner.config = { + "activeAgent": "ess-hr", + "agents": [{ + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": "gptagent_copilotforemployeeselfservicehr", + }], + } + _register_topics([ + _topic_record( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + user_context.SETUP_TOPIC_NAME, + "gptagent_copilotforemployeeselfservicehr.topic.Setusercontext", + user_context._redirect_yaml( + "gptagent_copilotforemployeeselfservicehr.topic." + "WorkdaySystemGetUserContextV2" + ), + ), + _topic_record( + "99999999-8888-7777-6666-555555555555", + user_context.TARGET_TOPIC_NAME, + "gptagent_copilotforemployeeselfservicehr.topic." + "WorkdaySystemGetUserContextV2", + "kind: AdaptiveDialog\n", + ), + ]) + + result = _check_workday_da_user_context(runner)[0] + + assert result.checkpoint_id == "WD-DA-CTX-001" + assert result.status == "Passed" + assert "redirects to the enabled" in result.result + assert result.remediation == "" + + +@responses.activate +def test_user_context_fails_with_click_path_when_redirect_is_missing( + runner: _MinimalRunner, +) -> None: + import configure_workday_da_user_context as user_context + + runner.config = { + "activeAgent": "ess-hr", + "agents": [{ + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": "gptagent_copilotforemployeeselfservicehr", + }], + } + _register_topics([ + _topic_record( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + user_context.SETUP_TOPIC_NAME, + "gptagent_copilotforemployeeselfservicehr.topic.Setusercontext", + "kind: AdaptiveDialog\nbeginDialog:\n kind: OnRedirect\n", + ), + _topic_record( + "99999999-8888-7777-6666-555555555555", + user_context.TARGET_TOPIC_NAME, + "gptagent_copilotforemployeeselfservicehr.topic." + "WorkdaySystemGetUserContextV2", + "kind: AdaptiveDialog\n", + ), + ]) + + result = _check_workday_da_user_context(runner)[0] + + assert result.status == "Failed" + assert "does not redirect exclusively" in result.result + assert "Select a topic" in result.remediation + + +@responses.activate +def test_user_context_fails_when_v2_target_is_disabled( + runner: _MinimalRunner, +) -> None: + import configure_workday_da_user_context as user_context + + runner.config = { + "activeAgent": "ess-hr", + "agents": [{ + "slug": "ess-hr", + "botId": BOT_ID, + "schemaName": "gptagent_copilotforemployeeselfservicehr", + }], + } + _register_topics([ + _topic_record( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + user_context.SETUP_TOPIC_NAME, + "gptagent_copilotforemployeeselfservicehr.topic.Setusercontext", + user_context._redirect_yaml( + "gptagent_copilotforemployeeselfservicehr.topic." + "WorkdaySystemGetUserContextV2" + ), + ), + _topic_record( + "99999999-8888-7777-6666-555555555555", + user_context.TARGET_TOPIC_NAME, + "gptagent_copilotforemployeeselfservicehr.topic." + "WorkdaySystemGetUserContextV2", + "kind: AdaptiveDialog\n", + statecode=1, + ), + ]) + + result = _check_workday_da_user_context(runner)[0] + + assert result.status == "Failed" + assert "exists and is selected, but it is disabled" in result.result + assert "Enable the Workday V2 user-context topic" in result.remediation diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index 155e51368..06a29a4fc 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -45,6 +45,7 @@ def _args( invocation_source: str | None = None, quiet_auth: bool = False, ring: str | None = None, + preferred_username: str | None = None, ) -> argparse.Namespace: return argparse.Namespace( checkpoint=checkpoint, @@ -57,6 +58,7 @@ def _args( invocation_source=invocation_source, quiet_auth=quiet_auth, ring=ring, + preferred_username=preferred_username, ) @@ -557,6 +559,114 @@ def _fn(runner): assert captured["config"]["tenant"] == "acme" assert captured["config"]["_connectConfigPath"] == str(overlay) + @pytest.mark.parametrize("requires_flow_token", [False, True]) + def test_power_platform_auth_requests_only_the_planned_audiences( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + _silence_output: None, + requires_flow_token: bool, + ) -> None: + captured = {} + + class _Spec: + category_label = "Fake" + is_family = False + + class _Plan: + clients = frozenset({registry.PP_ADMIN}) + requires_config = False + requires_dataverse_endpoint = False + + def __init__(self) -> None: + self.requires_flow_token = requires_flow_token + self.ordered_fns = [("Fake", self._fn)] + + @staticmethod + def _fn(runner): + assert runner.pp_admin is not None + return [_row("FAKE-001", Status.PASSED.value)] + + class _PowerPlatformAdmin: + def __init__(self, tenant_id: str) -> None: + assert tenant_id == "organizations" + + def authenticate(self, *, include_flow: bool = True) -> str: + captured["include_flow"] = include_flow + return "token" + + monkeypatch.setattr(registry, "resolve", lambda target: _Spec()) + monkeypatch.setattr( + registry, "transitive_requirements", lambda target: _Plan() + ) + monkeypatch.setattr(cli, "PPAdminClient", _PowerPlatformAdmin) + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc: + cli._run_single_checkpoint( + _args( + "FAKE-001", + tmp_path, + environment_id="00000000-0000-4000-8000-000000001111", + ) + ) + + assert exc.value.code == 0 + assert captured["include_flow"] is requires_flow_token + + def test_graph_auth_receives_the_preferred_account( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + _silence_output: None, + ) -> None: + captured = {} + + class _Spec: + category_label = "Fake" + is_family = False + + class _Plan: + clients = frozenset({registry.GRAPH}) + requires_config = False + requires_dataverse_endpoint = False + requires_flow_token = False + + def __init__(self) -> None: + self.ordered_fns = [("Fake", self._fn)] + + @staticmethod + def _fn(runner): + assert runner.graph is not None + return [_row("FAKE-001", Status.PASSED.value)] + + class _Graph: + def __init__(self, tenant_id: str) -> None: + assert tenant_id == "organizations" + + def authenticate(self, *, preferred_username: str) -> str: + captured["preferred_username"] = preferred_username + return "token" + + monkeypatch.setattr(registry, "resolve", lambda target: _Spec()) + monkeypatch.setattr( + registry, "transitive_requirements", lambda target: _Plan() + ) + monkeypatch.setattr(cli, "GraphClient", _Graph) + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc: + cli._run_single_checkpoint( + _args( + "FAKE-001", + tmp_path, + preferred_username="admin@contoso.com", + ) + ) + + assert exc.value.code == 0 + assert captured["preferred_username"] == "admin@contoso.com" + def test_failed_row_exits_1( self, tmp_path: Path, diff --git a/tests/flightcheck/test_graph_client.py b/tests/flightcheck/test_graph_client.py index 0aafa0356..e63bf0731 100644 --- a/tests/flightcheck/test_graph_client.py +++ b/tests/flightcheck/test_graph_client.py @@ -50,6 +50,80 @@ def test_empty_tenant_id_returns_empty(): assert graph_client.resolve_tenant_display_name_silent("") == "" +def test_authenticate_reuses_the_exact_preferred_cached_account(): + preferred = {"username": "admin@contoso.com"} + other = {"username": "other@contoso.com"} + app = MagicMock() + app.get_accounts.return_value = [other, preferred] + app.acquire_token_silent.return_value = {"access_token": "cached-token"} + + with patch.object( + graph_client.msal, + "PublicClientApplication", + return_value=app, + ): + client = graph_client.GraphClient("tenant-Z") + assert ( + client.authenticate(preferred_username="ADMIN@contoso.com") + == "cached-token" + ) + + app.acquire_token_silent.assert_called_once_with( + graph_client.GRAPH_SCOPES, + account=preferred, + ) + app.acquire_token_interactive.assert_not_called() + + +def test_authenticate_without_hint_preserves_first_cached_account_behavior(): + first = {"username": "first@contoso.com"} + app = MagicMock() + app.get_accounts.return_value = [ + first, + {"username": "second@contoso.com"}, + ] + app.acquire_token_silent.return_value = {"access_token": "cached-token"} + + with patch.object( + graph_client.msal, + "PublicClientApplication", + return_value=app, + ): + client = graph_client.GraphClient("tenant-Z") + assert client.authenticate() == "cached-token" + + app.acquire_token_silent.assert_called_once_with( + graph_client.GRAPH_SCOPES, + account=first, + ) + app.acquire_token_interactive.assert_not_called() + + +def test_authenticate_uses_login_hint_when_preferred_account_is_not_cached(): + app = MagicMock() + app.get_accounts.return_value = [{"username": "other@contoso.com"}] + app.acquire_token_interactive.return_value = { + "access_token": "interactive-token" + } + + with patch.object( + graph_client.msal, + "PublicClientApplication", + return_value=app, + ): + client = graph_client.GraphClient("tenant-Z") + assert ( + client.authenticate(preferred_username="admin@contoso.com") + == "interactive-token" + ) + + app.acquire_token_silent.assert_not_called() + app.acquire_token_interactive.assert_called_once_with( + graph_client.GRAPH_SCOPES, + login_hint="admin@contoso.com", + ) + + def test_no_cached_account_returns_empty_without_prompt(): app = _fake_app(accounts=[], silent_result=None) with patch.object(graph_client.msal, "PublicClientApplication", return_value=app): diff --git a/tests/flightcheck/test_registry.py b/tests/flightcheck/test_registry.py index e8882af57..3677d116a 100644 --- a/tests/flightcheck/test_registry.py +++ b/tests/flightcheck/test_registry.py @@ -175,6 +175,7 @@ def test_env002_pulls_env001_prereq(self): assert len(plan.ordered_fns) == 1 assert plan.requires_config is False assert plan.requires_dataverse_endpoint is True + assert plan.requires_flow_token is False def test_env_capacity_001_resolves_and_unions_powerplatform(self): spec = registry.resolve("ENV-CAPACITY-001") @@ -207,6 +208,28 @@ def test_native_agent_checkpoints_use_only_native_read_clients(self): "Native Agent" ] + def test_workday_da_agent_checks_use_only_their_required_clients(self): + package = registry.transitive_requirements("WD-DA-PKG-001") + assert package.clients == frozenset({registry.DATAVERSE}) + assert package.requires_dataverse_endpoint is True + assert package.requires_flow_token is False + assert [label for label, _ in package.ordered_fns] == ["Workday DA"] + + sharing = registry.transitive_requirements("WD-DA-CONN-001") + assert sharing.clients == frozenset({registry.AGENTBUILDER}) + assert sharing.requires_dataverse_endpoint is False + assert [label for label, _ in sharing.ordered_fns] == ["Workday DA"] + + context = registry.transitive_requirements("WD-DA-CTX-001") + assert context.clients == frozenset({registry.DATAVERSE}) + assert context.requires_dataverse_endpoint is True + assert [label for label, _ in context.ordered_fns] == ["Workday DA"] + + def test_workday_flow_checks_request_the_flow_admin_token(self): + plan = registry.transitive_requirements("WD-CONN-AUTH-001") + assert registry.PP_ADMIN in plan.clients + assert plan.requires_flow_token is True + def test_env009_is_individually_targetable_with_dataverse_only(self): spec = registry.resolve("ENV-009") assert spec is not None and spec.key == "ENV-009" diff --git a/tests/scripts/test_activate_workday_da_flows.py b/tests/scripts/test_activate_workday_da_flows.py new file mode 100644 index 000000000..fdbff9441 --- /dev/null +++ b/tests/scripts/test_activate_workday_da_flows.py @@ -0,0 +1,220 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for deterministic Workday DA cloud-flow activation.""" + +from __future__ import annotations + +from copy import deepcopy + +import pytest + + +FLOW_NAMES = [ + "ESS Workday Runtime References", + "ESS Workday Runtime REST Execution", + "ESS Workday Runtime", +] + + +def _definition() -> dict: + return { + "packages": { + "runtime": { + "flowNames": FLOW_NAMES, + }, + "legacy-da": {}, + } + } + + +def _reference(logical_name: str, connection_id: str | None) -> dict: + return { + "connectionreferencelogicalname": logical_name, + "connectionreferenceid": logical_name, + "connectionid": connection_id, + } + + +def _flow(name: str, index: int, *, active: bool = False) -> dict: + return { + "workflowid": f"00000000-0000-0000-0000-{index:012d}", + "name": name, + "statecode": 1 if active else 0, + "statuscode": 2 if active else 1, + "category": 5, + } + + +def _query(module, flows: list[dict], *, bound: bool = True): + references = [ + _reference( + module.WORKDAY_LOGICAL_NAME, + "workday-connection" if bound else None, + ), + _reference( + module.DATAVERSE_LOGICAL_NAME, + "dataverse-connection" if bound else None, + ), + ] + + def query(_url, _token, entity_set, _select, _filter): + if entity_set == "connectionreferences": + return deepcopy(references) + if entity_set == "workflows": + return deepcopy(flows) + raise AssertionError(f"Unexpected entity set: {entity_set}") + + return query + + +def test_preview_lists_reviewed_flows_without_mutating() -> None: + import activate_workday_da_flows as module + + flows = [_flow(name, index) for index, name in enumerate(FLOW_NAMES, 1)] + updates = [] + result = module.activate_workday_flows( + "https://org.crm.dynamics.com", + "runtime", + apply=False, + definition=_definition(), + token_provider=lambda *args, **kwargs: "token", + query=_query(module, flows), + updater=lambda *args, **kwargs: updates.append((args, kwargs)), + ) + + assert result["mode"] == "preview" + assert [change["name"] for change in result["changes"]] == FLOW_NAMES + assert all(change["action"] == "activate" for change in result["changes"]) + assert updates == [] + + +def test_apply_activates_draft_flows_and_reverifies() -> None: + import activate_workday_da_flows as module + + flows = [_flow(FLOW_NAMES[0], 1, active=True)] + [ + _flow(name, index) + for index, name in enumerate(FLOW_NAMES[1:], 2) + ] + updates = [] + + def update(_url, _token, entity_set, record_id, data): + assert entity_set == "workflows" + updates.append(record_id) + for flow in flows: + if flow["workflowid"] == record_id: + flow.update(data) + return True + raise AssertionError(f"Unknown workflow: {record_id}") + + result = module.activate_workday_flows( + "https://org.crm.dynamics.com/", + "runtime", + apply=True, + preferred_username="maker@example.com", + definition=_definition(), + token_provider=lambda *args, **kwargs: "token", + query=_query(module, flows), + updater=update, + ) + + assert result["verified"] is True + assert len(updates) == 2 + assert all( + flow["statecode"] == 1 and flow["statuscode"] == 2 + for flow in flows + ) + + +def test_apply_is_idempotent_when_all_flows_are_active() -> None: + import activate_workday_da_flows as module + + flows = [ + _flow(name, index, active=True) + for index, name in enumerate(FLOW_NAMES, 1) + ] + updates = [] + result = module.activate_workday_flows( + "https://org.crm.dynamics.com", + "runtime", + apply=True, + definition=_definition(), + token_provider=lambda *args, **kwargs: "token", + query=_query(module, flows), + updater=lambda *args, **kwargs: updates.append((args, kwargs)), + ) + + assert result["verified"] is True + assert all(change["action"] == "unchanged" for change in result["changes"]) + assert updates == [] + + +def test_unbound_runtime_reference_fails_closed() -> None: + import activate_workday_da_flows as module + + flows = [_flow(name, index) for index, name in enumerate(FLOW_NAMES, 1)] + with pytest.raises( + module.WorkdayDAFlowActivationError, + match="must be bound before flow activation", + ): + module.activate_workday_flows( + "https://org.crm.dynamics.com", + "runtime", + apply=False, + definition=_definition(), + token_provider=lambda *args, **kwargs: "token", + query=_query(module, flows, bound=False), + ) + + +def test_duplicate_or_non_cloud_flow_fails_closed() -> None: + import activate_workday_da_flows as module + + duplicate = [_flow(name, index) for index, name in enumerate(FLOW_NAMES, 1)] + duplicate.append(_flow(FLOW_NAMES[0], 99)) + with pytest.raises( + module.WorkdayDAFlowActivationError, + match="Expected exactly one installed Workday flow", + ): + module.activate_workday_flows( + "https://org.crm.dynamics.com", + "runtime", + apply=False, + definition=_definition(), + token_provider=lambda *args, **kwargs: "token", + query=_query(module, duplicate), + ) + + non_cloud = [ + _flow(name, index) + for index, name in enumerate(FLOW_NAMES, 1) + ] + non_cloud[0]["category"] = 0 + with pytest.raises( + module.WorkdayDAFlowActivationError, + match="Refusing to activate non-cloud workflow", + ): + module.activate_workday_flows( + "https://org.crm.dynamics.com", + "runtime", + apply=False, + definition=_definition(), + token_provider=lambda *args, **kwargs: "token", + query=_query(module, non_cloud), + ) + + +def test_package_without_reviewed_flow_catalog_requires_manual_activation() -> None: + import activate_workday_da_flows as module + + with pytest.raises( + module.WorkdayDAFlowActivationError, + match="has no reviewed flow catalog", + ): + module.activate_workday_flows( + "https://org.crm.dynamics.com", + "legacy-da", + apply=False, + definition=_definition(), + token_provider=lambda *args, **kwargs: "token", + ) diff --git a/tests/scripts/test_bind_workday_da_connections.py b/tests/scripts/test_bind_workday_da_connections.py new file mode 100644 index 000000000..c3e504293 --- /dev/null +++ b/tests/scripts/test_bind_workday_da_connections.py @@ -0,0 +1,216 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for deterministic Workday DA runtime connection binding.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def _connection(name: str, connector: str, *, connected: bool = True) -> dict: + return { + "name": name, + "properties": { + "apiId": f"/providers/Microsoft.PowerApps/apis/{connector}", + "displayName": connector, + "statuses": [ + {"status": "Connected" if connected else "Error"} + ], + }, + } + + +def _reference( + record_id: str, + logical_name: str, + connection_id: str | None = None, +) -> dict: + return { + "connectionreferenceid": record_id, + "connectionreferencelogicalname": logical_name, + "connectionreferencedisplayname": logical_name, + "connectionid": connection_id, + "statuscode": 1, + } + + +def _pac_runner(connections: list[dict]): + def runner(command, *, timeout): + assert command[1:4] == [ + "connectivity", + "list-connections", + "--environment", + ] + assert command[-1] == "--json" + return SimpleNamespace( + returncode=0, + stdout=json.dumps({"value": connections}), + stderr="", + ) + + return runner + + +def _references(module, bindings: dict[str, str | None]) -> list[dict]: + return [ + _reference("workday-ref", module.WORKDAY_LOGICAL_NAME, bindings["wd"]), + _reference( + "dataverse-ref", + module.DATAVERSE_LOGICAL_NAME, + bindings["dv"], + ), + ] + + +def test_preview_reports_both_required_bindings_without_mutating() -> None: + import bind_workday_da_connections as module + + bindings = {"wd": None, "dv": None} + updates = [] + result = module.bind_runtime_connections( + "https://org.crm.dynamics.com", + ring="prod", + apply=False, + pac_resolver=lambda: Path("pac.exe"), + pac_auth=lambda *args, **kwargs: None, + runner=_pac_runner( + [ + _connection("wd-connection", module.WORKDAY_CONNECTOR), + _connection("dv-connection", module.DATAVERSE_CONNECTOR), + ] + ), + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: _references(module, bindings), + updater=lambda *args, **kwargs: updates.append((args, kwargs)), + ) + + assert result["mode"] == "preview" + assert [change["action"] for change in result["changes"]] == [ + "bind", + "bind", + ] + assert updates == [] + + +def test_apply_updates_and_reverifies_both_references() -> None: + import bind_workday_da_connections as module + + bindings = {"wd": None, "dv": None} + pac_auth_calls = [] + + def update(_url, _token, _entity, record_id, data): + bindings["wd" if record_id == "workday-ref" else "dv"] = data[ + "connectionid" + ] + return True + + result = module.bind_runtime_connections( + "https://org.crm10.dynamics.com/", + ring="preprod", + apply=True, + preferred_username="maker@example.com", + pac_resolver=lambda: Path("pac.exe"), + pac_auth=lambda *args, **kwargs: pac_auth_calls.append( + (args, kwargs) + ), + runner=_pac_runner( + [ + _connection("wd-connection", module.WORKDAY_CONNECTOR), + _connection("dv-connection", module.DATAVERSE_CONNECTOR), + ] + ), + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: _references(module, bindings), + updater=update, + ) + + assert result["mode"] == "apply" + assert result["verified"] is True + assert bindings == {"wd": "wd-connection", "dv": "dv-connection"} + assert pac_auth_calls[0][1]["preferred_username"] == "maker@example.com" + + +def test_apply_is_idempotent_when_bindings_already_match() -> None: + import bind_workday_da_connections as module + + bindings = {"wd": "wd-connection", "dv": "dv-connection"} + updates = [] + result = module.bind_runtime_connections( + "https://org.crm.dynamics.com", + ring="prod", + apply=True, + pac_resolver=lambda: Path("pac.exe"), + pac_auth=lambda *args, **kwargs: None, + runner=_pac_runner( + [ + _connection("wd-connection", module.WORKDAY_CONNECTOR), + _connection("dv-connection", module.DATAVERSE_CONNECTOR), + ] + ), + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: _references(module, bindings), + updater=lambda *args, **kwargs: updates.append((args, kwargs)), + ) + + assert result["verified"] is True + assert all(change["action"] == "unchanged" for change in result["changes"]) + assert updates == [] + + +def test_multiple_connected_workday_connections_fail_closed() -> None: + import bind_workday_da_connections as module + + with pytest.raises( + module.WorkdayDABindingError, + match="Expected exactly one connected shared_workdaysoap connection", + ): + module.bind_runtime_connections( + "https://org.crm.dynamics.com", + ring="prod", + apply=False, + pac_resolver=lambda: Path("pac.exe"), + pac_auth=lambda *args, **kwargs: None, + runner=_pac_runner( + [ + _connection("wd-one", module.WORKDAY_CONNECTOR), + _connection("wd-two", module.WORKDAY_CONNECTOR), + _connection("dv-one", module.DATAVERSE_CONNECTOR), + ] + ), + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: [], + ) + + +def test_duplicate_runtime_reference_fails_closed() -> None: + import bind_workday_da_connections as module + + rows = [ + _reference("wd-one", module.WORKDAY_LOGICAL_NAME), + _reference("wd-two", module.WORKDAY_LOGICAL_NAME), + _reference("dv-one", module.DATAVERSE_LOGICAL_NAME), + ] + with pytest.raises( + module.WorkdayDABindingError, + match="Expected exactly one installed runtime connection reference", + ): + module.bind_runtime_connections( + "https://org.crm.dynamics.com", + ring="prod", + apply=False, + pac_resolver=lambda: Path("pac.exe"), + pac_auth=lambda *args, **kwargs: None, + runner=_pac_runner( + [ + _connection("wd-one", module.WORKDAY_CONNECTOR), + _connection("dv-one", module.DATAVERSE_CONNECTOR), + ] + ), + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: rows, + ) diff --git a/tests/scripts/test_configure_workday_da_user_context.py b/tests/scripts/test_configure_workday_da_user_context.py new file mode 100644 index 000000000..8b5beca82 --- /dev/null +++ b/tests/scripts/test_configure_workday_da_user_context.py @@ -0,0 +1,176 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for deterministic Workday DA user-context configuration.""" + +from __future__ import annotations + +import pytest + + +BOT_ID = "11111111-2222-3333-4444-555555555555" +SETUP_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +TARGET_ID = "99999999-8888-7777-6666-555555555555" +SETUP_SCHEMA = "gptagent_copilotforemployeeselfservicehr.topic.Setusercontext" +TARGET_SCHEMA = ( + "gptagent_copilotforemployeeselfservicehr.topic." + "WorkdaySystemGetUserContextV2" +) + + +def _topics(*, setup_data: str = "", target_active: bool = True) -> list[dict]: + return [ + { + "botcomponentid": SETUP_ID, + "name": "[Admin] - User Context - Setup", + "schemaname": SETUP_SCHEMA, + "data": setup_data, + "statecode": 0, + }, + { + "botcomponentid": TARGET_ID, + "name": "Workday [System] - 1: Set User Context V2", + "schemaname": TARGET_SCHEMA, + "data": "kind: AdaptiveDialog\n", + "statecode": 0 if target_active else 1, + }, + ] + + +def _bare_setup() -> str: + return ( + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: OnRedirect\n" + " id: main\n" + " priority: 0\n" + ) + + +def _configured_setup(module) -> str: + return module._redirect_yaml(TARGET_SCHEMA) + + +def test_preview_reports_safe_configuration_without_mutating() -> None: + import configure_workday_da_user_context as module + + rows = _topics(setup_data=_bare_setup(), target_active=False) + updates = [] + result = module.configure_workday_da_user_context( + "https://org.crm.dynamics.com/", + BOT_ID, + apply=False, + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: rows, + updater=lambda *args, **kwargs: updates.append((args, kwargs)), + ) + + assert result["mode"] == "preview" + assert result["action"] == "configure" + assert result["redirectConfigured"] is False + assert result["targetTopicActive"] is False + assert updates == [] + + +def test_apply_updates_and_verifies_the_exact_redirect() -> None: + import configure_workday_da_user_context as module + + rows = _topics(setup_data=_bare_setup()) + + def update(_url, _token, entity_set, record_id, data): + assert entity_set == "botcomponents" + assert record_id == SETUP_ID + rows[0]["data"] = data["data"] + return True + + result = module.configure_workday_da_user_context( + "https://org.crm.dynamics.com", + BOT_ID, + apply=True, + preferred_username="maker@example.com", + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: rows, + updater=update, + ) + + assert result["verified"] is True + assert result["action"] == "configured" + assert result["redirectConfigured"] is True + assert f"dialog: {TARGET_SCHEMA}" in rows[0]["data"] + + +def test_apply_is_idempotent_when_redirect_is_already_correct() -> None: + import configure_workday_da_user_context as module + + rows = _topics(setup_data=_configured_setup(module)) + updates = [] + result = module.configure_workday_da_user_context( + "https://org.crm.dynamics.com", + BOT_ID, + apply=True, + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: rows, + updater=lambda *args, **kwargs: updates.append((args, kwargs)), + ) + + assert result["verified"] is True + assert result["action"] == "unchanged" + assert updates == [] + + +def test_custom_setup_content_is_never_overwritten() -> None: + import configure_workday_da_user_context as module + + rows = _topics( + setup_data=( + "kind: AdaptiveDialog\n" + "beginDialog:\n" + " kind: OnRedirect\n" + " actions:\n" + " - kind: SendActivity\n" + " activity: Keep me\n" + ) + ) + with pytest.raises( + module.WorkdayDAUserContextError, + match="contains custom actions", + ): + module.configure_workday_da_user_context( + "https://org.crm.dynamics.com", + BOT_ID, + apply=True, + token_provider=lambda *args, **kwargs: "token", + query=lambda *args, **kwargs: rows, + ) + + +def test_duplicate_target_topics_fail_closed() -> None: + import configure_workday_da_user_context as module + + rows = _topics() + rows.append({**rows[1], "botcomponentid": "duplicate"}) + with pytest.raises( + module.WorkdayDAUserContextError, + match="found 2", + ): + module.inspect_workday_da_user_context( + "https://org.crm.dynamics.com", + "token", + BOT_ID, + query=lambda *args, **kwargs: rows, + ) + + +def test_invalid_bot_id_fails_before_query() -> None: + import configure_workday_da_user_context as module + + with pytest.raises( + module.WorkdayDAUserContextError, + match="invalid bot ID", + ): + module.inspect_workday_da_user_context( + "https://org.crm.dynamics.com", + "token", + "not-a-guid", + query=lambda *args, **kwargs: pytest.fail("query must not run"), + ) diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py index 99a6776ff..a9f883c8b 100644 --- a/tests/scripts/test_install_workday_da_extension.py +++ b/tests/scripts/test_install_workday_da_extension.py @@ -31,12 +31,14 @@ def test_parse_profiles_reads_cloud_and_active_marker(): { "index": "1", "active": False, + "username": "user@contoso.com", "cloud": "Public", "environment_url": None, }, { "index": "2", "active": True, + "username": "user@contoso.com", "cloud": "Preprod", "environment_url": "https://org.crm10.dynamics.com", }, @@ -262,6 +264,69 @@ def runner(command, *, capture_output, timeout): ) +def test_preprod_auth_selects_exact_environment_and_username(): + import install_workday_da_extension as m + + calls = [] + + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + if command[1:3] == ["auth", "list"]: + return _result( + stdout=( + "[1] user1@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + "[2] user2@contoso.com Preprod " + "https://org.crm10.dynamics.com/\n" + ) + ) + return _result() + + m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + preferred_username="user2@contoso.com", + runner=runner, + ) + + assert calls[-1] == [ + "pac.exe", + "auth", + "select", + "--index", + "2", + ] + + +def test_preprod_auth_rejects_wrong_account_after_profile_creation(): + import install_workday_da_extension as m + + auth_lists = iter( + [ + "", + ( + "[1] * wrong@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + ), + ] + ) + + def runner(command, *, capture_output, timeout): + if command[1:3] == ["auth", "list"]: + return _result(stdout=next(auth_lists)) + return _result() + + with pytest.raises(m.PacCliError, match="does not match the requested"): + m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + preferred_username="maker@contoso.com", + runner=runner, + ) + + def test_legacy_da_uses_targeted_appsource_application(): import install_workday_da_extension as m diff --git a/tests/scripts/test_workday_da_contract.py b/tests/scripts/test_workday_da_contract.py new file mode 100644 index 000000000..61beea5c3 --- /dev/null +++ b/tests/scripts/test_workday_da_contract.py @@ -0,0 +1,257 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the versioned Workday DA setup contract.""" + +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path +import re + +import pytest + +from workday_da_contract import ( + DEFAULT_DEFINITION_PATH, + WorkdayDAContractError, + assess_package_version, + load_definition, + parse_solution_version, + retry_schedule, + validate_definition, + validate_state, +) + + +REPO_ROOT = Path(__file__).parents[2] +TASKS_PATH = ( + REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "setup" + / "workday-da" + / "tasks.md" +) +VISIBLE_ROW = re.compile( + r"^- \[[ x]\] \*\*(?P.+?)\*\* — (?P<description>.+)$" +) + + +def _task_rows() -> dict[str, dict[str, str]]: + lines = TASKS_PATH.read_text(encoding="utf-8").splitlines() + rows: dict[str, dict[str, str]] = {} + for index, line in enumerate(lines[:-1]): + visible = VISIBLE_ROW.match(line) + if not visible: + continue + metadata_line = lines[index + 1].strip() + assert metadata_line.startswith("<!-- ") and metadata_line.endswith(" -->") + metadata = {} + for field in metadata_line[5:-4].split(" | "): + key, value = field.split(": ", 1) + metadata[key] = value + rows[metadata["id"]] = { + **visible.groupdict(), + **metadata, + } + return rows + + +def test_default_definition_is_valid_and_complete() -> None: + definition = load_definition() + + assert DEFAULT_DEFINITION_PATH.is_file() + assert definition["provider"] == "workday" + assert definition["definitionVersion"] == 1 + assert definition["stateSchemaVersion"] == 1 + assert len(definition["steps"]) == 21 + assert [milestone["id"] for milestone in definition["customerMilestones"]] == [ + "preflight", + "microsoft-entra", + "workday-administrator", + "connections", + "runtime-configuration", + "network-readiness", + "employee-validation", + ] + assert { + step_id + for milestone in definition["customerMilestones"] + for step_id in milestone["stepIds"] + } == {step["id"] for step in definition["steps"]} + assert definition["packages"]["runtime"]["flowNames"] == [ + "ESS Workday Runtime References", + "ESS Workday Runtime REST Execution", + "ESS Workday Runtime", + ] + assert definition["completion"]["finalStepId"] == "DA5.1" + assert set(definition["completion"]["requiredStepIds"]) == { + step["id"] for step in definition["steps"] + } + + +def test_definition_matches_the_checked_in_checklist_template() -> None: + definition = load_definition() + task_rows = _task_rows() + + assert set(task_rows) == {step["id"] for step in definition["steps"]} + for step in definition["steps"]: + row = task_rows[step["id"]] + assert row["title"] == step["title"] + assert row["description"] == step["description"] + assert row["role"] == step["role"] + assert row["skill"] == step["owner"] + assert row["automatable"] == step["automationLabel"] + assert row["checkpoints"] == step["checkpointLabel"] + assert row["gate"] == step["gateLabel"] + assert row["status"] == "pending" + + +def test_definition_rejects_unknown_dependency() -> None: + definition = load_definition() + invalid = deepcopy(definition) + invalid["steps"][0]["dependsOn"] = ["DA5.9"] + + with pytest.raises(WorkdayDAContractError, match="unknown dependency DA5.9"): + validate_definition(invalid) + + +def test_definition_rejects_duplicate_customer_milestone_assignment() -> None: + definition = load_definition() + invalid = deepcopy(definition) + invalid["customerMilestones"][1]["stepIds"].append("DA1.1") + + with pytest.raises( + WorkdayDAContractError, + match="assigned to multiple customer milestones", + ): + validate_definition(invalid) + + +def test_definition_requires_every_step_in_a_customer_milestone() -> None: + definition = load_definition() + invalid = deepcopy(definition) + invalid["customerMilestones"][4]["stepIds"].remove("DA4.7") + + with pytest.raises(WorkdayDAContractError, match="do not cover steps: DA4.7"): + validate_definition(invalid) + + +def test_definition_rejects_dependency_cycle() -> None: + definition = load_definition() + invalid = deepcopy(definition) + invalid["steps"][0]["dependsOn"] = ["DA5.1"] + + with pytest.raises(WorkdayDAContractError, match="contain a cycle"): + validate_definition(invalid) + + +def test_definition_rejects_unknown_package_flavor() -> None: + definition = load_definition() + invalid = deepcopy(definition) + invalid["architectures"][0]["packageFlavor"] = "missing" + + with pytest.raises(WorkdayDAContractError, match="unknown package flavor"): + validate_definition(invalid) + + +def test_state_schema_accepts_migrated_minimal_state() -> None: + validate_state( + { + "definitionVersion": 1, + "stateSchemaVersion": 1, + "status": "in-progress", + "vertical": "hr", + "verticals": ["hr"], + "setupStatus": { + "DA1.1": { + "state": "pending", + "checkpoint": "WD-DA-PKG-001", + "gate": "prog", + "verifiedBy": None, + } + }, + } + ) + + +def test_state_schema_rejects_invalid_completion_marker() -> None: + with pytest.raises(WorkdayDAContractError, match="verifiedBy"): + validate_state( + { + "definitionVersion": 1, + "stateSchemaVersion": 1, + "status": "in-progress", + "setupStatus": { + "DA1.1": { + "state": "done", + "checkpoint": "WD-DA-PKG-001", + "gate": "prog", + "verifiedBy": "claimed", + } + }, + } + ) + + +def test_solution_versions_require_four_numeric_components() -> None: + assert parse_solution_version("2.10.3.4") == (2, 10, 3, 4) + with pytest.raises(WorkdayDAContractError, match="four numeric components"): + parse_solution_version("2.10.3") + with pytest.raises(WorkdayDAContractError, match="four numeric components"): + parse_solution_version("2.10.preview.4") + + +def test_pending_package_policy_records_without_claiming_support() -> None: + assessment = assess_package_version("runtime", "2.1.0.0") + + assert assessment.outcome == "pending-policy" + assert "awaiting product confirmation" in assessment.message + + +def test_enforced_package_policy_applies_inclusive_minimum_and_exclusive_maximum() -> None: + definition = load_definition() + definition["packages"]["runtime"]["versionPolicy"] = { + "status": "enforced", + "minimumInclusive": "2.1.0.0", + "maximumExclusive": "3.0.0.0", + } + validate_definition(definition) + + assert assess_package_version( + "runtime", "2.1.0.0", definition=definition + ).outcome == "supported" + assert assess_package_version( + "runtime", "2.9.9.9", definition=definition + ).outcome == "supported" + assert assess_package_version( + "runtime", "2.0.9.9", definition=definition + ).outcome == "unsupported" + assert assess_package_version( + "runtime", "3.0.0.0", definition=definition + ).outcome == "unsupported" + + +def test_enforced_package_policy_requires_a_minimum() -> None: + definition = load_definition() + definition["packages"]["runtime"]["versionPolicy"]["status"] = "enforced" + + with pytest.raises(WorkdayDAContractError, match="minimumInclusive"): + validate_definition(definition) + + +def test_failure_policy_retries_only_safe_transient_operations() -> None: + assert retry_schedule("transient", "read") == (2, 5) + assert retry_schedule("transient", "idempotent") == (2, 5) + assert retry_schedule("transient", "mutation") == () + assert retry_schedule("permission", "read") == () + assert retry_schedule("ambiguous-mutation", "idempotent") == () + + +def test_failure_policy_rejects_unknown_categories_and_operations() -> None: + with pytest.raises(WorkdayDAContractError, match="failure category"): + retry_schedule("mystery", "read") + with pytest.raises(WorkdayDAContractError, match="operation kind"): + retry_schedule("transient", "guess") diff --git a/tests/scripts/test_workday_da_journey.py b/tests/scripts/test_workday_da_journey.py new file mode 100644 index 000000000..c027fe65d --- /dev/null +++ b/tests/scripts/test_workday_da_journey.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Outcome-based Workday DA journey evaluations across multiple turns.""" + +from __future__ import annotations + +import json + +from workday_da_state import WorkdayDAStateStore, _atomic_write_text + + +def _evidence(note: str) -> dict: + return { + "outcome": "CONFIRMED", + "provenance": "user-acknowledgement", + "note": note, + "capturedAt": "2026-09-25T12:00:00Z", + } + + +def test_fresh_setup_resume_drift_and_repair_journey(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + + first_turn = store.initialize() + assert first_turn["status"] == "in-progress" + assert first_turn["setupStatus"]["DA1.1"]["state"] == "pending" + + store.update_row("DA1.1", checkpoint_result="Passed") + config = json.loads(store.config_path.read_text(encoding="utf-8")) + config["tenant"] = "tenant-one" + _atomic_write_text(store.config_path, json.dumps(config, indent=2) + "\n") + store.update_row( + "DA2.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence=_evidence("Workday SSO application confirmed."), + ) + + second_turn = store.revalidation_plan() + assert any( + action["stepId"] == "DA1.1" + and action["mode"] == "checkpoint" + for action in second_turn["actions"] + ) + assert second_turn["config"]["setupStatus"]["DA2.1"]["state"] == "done" + + config = json.loads(store.config_path.read_text(encoding="utf-8")) + config["tenant"] = "tenant-two" + _atomic_write_text(store.config_path, json.dumps(config, indent=2) + "\n") + third_turn = store.revalidation_plan() + + assert any( + action["stepId"] == "DA2.1" + and action["mode"] == "manual-evidence-stale" + for action in third_turn["actions"] + ) + assert third_turn["config"]["setupStatus"]["DA2.1"]["state"] == "in-progress" + assert third_turn["config"]["status"] == "in-progress" + + +def test_failed_revalidation_blocks_and_invalidates_downstream_journey( + tmp_path, +) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + store.update_row("DA1.1", checkpoint_result="Passed") + store.revalidation_plan() + + failed = store.update_row("DA1.1", checkpoint_result="Failed") + + assert failed["setupStatus"]["DA1.1"]["state"] == "blocked" + assert failed["setupStatus"]["DA5.1"]["state"] == "in-progress" + assert "revalidation" not in failed + assert failed["status"] == "in-progress" diff --git a/tests/scripts/test_workday_da_state.py b/tests/scripts/test_workday_da_state.py new file mode 100644 index 000000000..1f2e3dc8b --- /dev/null +++ b/tests/scripts/test_workday_da_state.py @@ -0,0 +1,563 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for deterministic Workday DA persisted-state operations.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import portalocker +import pytest + +import workday_da_state as state_module +from workday_da_state import ( + WorkdayDAStateConflictError, + WorkdayDAStateError, + WorkdayDAStateLockError, + WorkdayDAStateStore, +) + + +TASK_TEMPLATE = ( + Path(__file__).parents[2] + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "setup" + / "workday-da" + / "tasks.md" +) + + +def _config(root: Path) -> dict: + return json.loads( + (root / ".local/connect/workday-da/config.json").read_text( + encoding="utf-8" + ) + ) + + +def _evidence(note: str = "Operator confirmed the current step.") -> dict: + return { + "outcome": "CONFIRMED", + "provenance": "user-acknowledgement", + "note": note, + "capturedAt": "2026-09-25T12:00:00Z", + } + + +def _mark_done(store: WorkdayDAStateStore, *step_ids: str) -> None: + config = _config(store.workspace_root) + for step_id in step_ids: + row = config["setupStatus"][step_id] + row["state"] = "done" + row["verifiedBy"] = ( + "programmatic" if row["gate"] == "prog" else "attested" + ) + row["evidence"] = _evidence(f"Fixture completed {step_id}.") + state_module._atomic_write_text( + store.config_path, json.dumps(config, indent=2) + "\n" + ) + + +def test_initialize_creates_complete_versioned_state_and_checklist(tmp_path) -> None: + config = WorkdayDAStateStore(tmp_path).initialize() + + assert config["definitionVersion"] == 1 + assert config["stateSchemaVersion"] == 1 + assert config["status"] == "in-progress" + assert len(config["setupStatus"]) == 21 + assert all( + row["state"] == "pending" for row in config["setupStatus"].values() + ) + checklist = ( + tmp_path / ".local/connect/workday-da/tasks.md" + ).read_text(encoding="utf-8") + assert checklist.count("status: pending") == 21 + assert ".local/setup/workday-da/tasks.md" not in str( + WorkdayDAStateStore(tmp_path).checklist_path + ) + + +def test_customer_status_projects_internal_rows_into_seven_milestones( + tmp_path, +) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + + initial = store.customer_status() + + assert len(initial["milestones"]) == 7 + assert initial["nextMilestoneId"] == "preflight" + assert {milestone["state"] for milestone in initial["milestones"]} == { + "pending" + } + + store.update_row("DA1.1", checkpoint_result="Passed") + progressed = store.customer_status() + + assert progressed["milestones"][0]["state"] == "done" + assert progressed["milestones"][1]["state"] == "pending" + assert progressed["nextMilestoneId"] == "microsoft-entra" + + +def test_initialize_moves_legacy_checklist_without_rewriting_it(tmp_path) -> None: + legacy = tmp_path / ".local/setup/workday-da/tasks.md" + legacy.parent.mkdir(parents=True) + content = TASK_TEMPLATE.read_text(encoding="utf-8").replace( + "status: pending", "status: done", 1 + ).replace("- [ ] **Install", "- [x] **Install", 1) + legacy.write_text(content, encoding="utf-8") + config_path = tmp_path / ".local/connect/workday-da/config.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps( + { + "status": "in-progress", + "setupStatus": { + "DA1.1": { + "state": "done", + "checkpoint": "WD-DA-PKG-001", + "gate": "prog", + "verifiedBy": "programmatic", + "evidence": { + "outcome": "PASSED", + "provenance": "flightcheck", + "note": "Required package detected.", + "capturedAt": "2026-09-25T12:00:00Z", + }, + } + }, + }, + indent=2, + ), + encoding="utf-8", + ) + timestamp = 1_700_000_000 + os.utime(legacy, (timestamp, timestamp)) + + config = WorkdayDAStateStore(tmp_path).initialize() + canonical = tmp_path / ".local/connect/workday-da/tasks.md" + + assert not legacy.exists() + assert canonical.read_text(encoding="utf-8") == content + assert canonical.stat().st_mtime == pytest.approx(timestamp, abs=1) + assert config["setupStatus"]["DA1.1"]["state"] == "done" + + +def test_initialize_refuses_dual_checklist_copies(tmp_path) -> None: + canonical = tmp_path / ".local/connect/workday-da/tasks.md" + legacy = tmp_path / ".local/setup/workday-da/tasks.md" + canonical.parent.mkdir(parents=True) + legacy.parent.mkdir(parents=True) + canonical.write_text("canonical", encoding="utf-8") + legacy.write_text("legacy", encoding="utf-8") + + with pytest.raises(WorkdayDAStateConflictError, match="Both canonical"): + WorkdayDAStateStore(tmp_path).initialize() + + assert canonical.read_text(encoding="utf-8") == "canonical" + assert legacy.read_text(encoding="utf-8") == "legacy" + + +def test_initialize_refuses_corrupt_config_instead_of_resetting_it(tmp_path) -> None: + path = tmp_path / ".local/connect/workday-da/config.json" + path.parent.mkdir(parents=True) + path.write_text("{not-json", encoding="utf-8") + + with pytest.raises(WorkdayDAStateError, match="not valid JSON"): + WorkdayDAStateStore(tmp_path).initialize() + + assert path.read_text(encoding="utf-8") == "{not-json" + + +def test_validate_rejects_partial_state_that_does_not_match_definition( + tmp_path, +) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + config = _config(tmp_path) + del config["setupStatus"]["DA5.1"] + state_module._atomic_write_text( + store.config_path, json.dumps(config, indent=2) + "\n" + ) + + with pytest.raises(WorkdayDAStateError, match="missing DA5.1"): + store.validate() + + +def test_programmatic_pass_completes_row_and_updates_view(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + + config = store.update_row( + "DA1.1", + checkpoint_result="Passed", + ) + + row = config["setupStatus"]["DA1.1"] + assert row["state"] == "done" + assert row["verifiedBy"] == "programmatic" + assert row["evidence"]["provenance"] == "flightcheck" + assert len(row["evidence"]["scopeFingerprint"]) == 64 + checklist = store.checklist_path.read_text(encoding="utf-8") + assert "- [x] **Install the Workday extension package**" in checklist + assert "id: DA1.1" in checklist + assert "status: done" in checklist + + +def test_manual_row_requires_ack_and_structured_evidence(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + store.update_row("DA1.1", checkpoint_result="Passed") + + without_evidence = store.update_row( + "DA2.1", + checkpoint_result="Manual", + ack=True, + ) + assert without_evidence["setupStatus"]["DA2.1"]["state"] == "in-progress" + + completed = store.update_row( + "DA2.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence=_evidence(), + ) + row = completed["setupStatus"]["DA2.1"] + assert row["state"] == "done" + assert row["verifiedBy"] == "attested" + + +def test_external_programmatic_pass_requires_evidence(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + _mark_done(store, "DA4.1", "DA4.2") + + missing = store.update_row( + "DA4.3", + checkpoint_result="Passed", + result_source="external", + ) + assert missing["setupStatus"]["DA4.3"]["state"] == "in-progress" + + completed = store.update_row( + "DA4.3", + checkpoint_result="Passed", + result_source="external", + evidence={ + "outcome": "PASSED", + "provenance": "external-operation", + "note": "Both connection references were verified after update.", + "capturedAt": "2026-09-25T12:00:00Z", + }, + ) + assert completed["setupStatus"]["DA4.3"]["state"] == "done" + + +def test_completion_rejects_pending_prerequisites(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + + with pytest.raises( + WorkdayDAStateError, + match="Cannot complete DA2.1; prerequisites are not done: DA1.1", + ): + store.update_row( + "DA2.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence=_evidence(), + ) + + +def test_failure_overrides_ack_and_regresses_transitive_dependents(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + store.update_row("DA1.1", checkpoint_result="Passed") + store.update_row( + "DA2.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence=_evidence(), + ) + config = _config(tmp_path) + config["setupStatus"]["DA5.1"].update( + { + "state": "done", + "verifiedBy": "attested", + "evidence": _evidence("Final scenario passed."), + } + ) + state_module._atomic_write_text( + store.config_path, json.dumps(config, indent=2) + "\n" + ) + + failed = store.update_row( + "DA1.1", + checkpoint_result="Failed", + ack=True, + ) + + assert failed["setupStatus"]["DA1.1"]["state"] == "blocked" + assert failed["setupStatus"]["DA1.1"]["verifiedBy"] is None + assert failed["setupStatus"]["DA2.1"]["state"] == "in-progress" + assert failed["setupStatus"]["DA5.1"]["state"] == "in-progress" + assert failed["status"] == "in-progress" + evidence = failed["setupStatus"]["DA1.1"]["evidence"] + assert evidence["failureCategory"] == "verification-failed" + assert evidence["retryable"] is False + assert evidence["attemptCount"] == 1 + + +def test_unknown_config_fields_survive_round_trip(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + config = _config(tmp_path) + config["futureProviderField"] = {"keep": True} + state_module._atomic_write_text( + store.config_path, json.dumps(config, indent=2) + "\n" + ) + + updated = store.update_row("DA1.1", checkpoint_result="Passed") + + assert updated["futureProviderField"] == {"keep": True} + + +def test_config_commit_survives_checklist_write_failure_and_reconcile_repairs( + tmp_path, monkeypatch +) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + real_write = store._write_checklist + + def fail_checklist(_config): + raise OSError("simulated view failure") + + monkeypatch.setattr(store, "_write_checklist", fail_checklist) + with pytest.raises(WorkdayDAStateError, match="config was saved"): + store.update_row("DA1.1", checkpoint_result="Passed") + + assert _config(tmp_path)["setupStatus"]["DA1.1"]["state"] == "done" + monkeypatch.setattr(store, "_write_checklist", real_write) + store.reconcile() + assert "- [x] **Install the Workday extension package**" in ( + store.checklist_path.read_text(encoding="utf-8") + ) + + +def test_lock_contention_fails_without_mutation(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path, lock_timeout=0.05) + store.initialize() + before = store.config_path.read_text(encoding="utf-8") + + with portalocker.Lock( + str(store.lock_path), + mode="a+", + timeout=0, + encoding="utf-8", + ): + with pytest.raises(WorkdayDAStateLockError, match="Another /connect"): + store.update_row("DA1.1", checkpoint_result="Passed") + + assert store.config_path.read_text(encoding="utf-8") == before + + +def test_regress_row_clears_completion_and_derived_readiness(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + config = _config(tmp_path) + for row in config["setupStatus"].values(): + row["state"] = "done" + row["verifiedBy"] = ( + "programmatic" if row["gate"] == "prog" else "attested" + ) + config["status"] = "ready" + state_module._atomic_write_text( + store.config_path, json.dumps(config, indent=2) + "\n" + ) + + regressed = store.regress_row("DA3.2") + + assert regressed["setupStatus"]["DA3.2"]["state"] == "in-progress" + assert regressed["setupStatus"]["DA4.1"]["state"] == "in-progress" + assert regressed["setupStatus"]["DA5.1"]["state"] == "in-progress" + assert regressed["status"] == "in-progress" + + +def test_resume_requires_fresh_programmatic_verification(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + store.update_row("DA1.1", checkpoint_result="Passed") + + plan = store.revalidation_plan() + + assert plan["actions"] == [ + { + "stepId": "DA1.1", + "owner": "da-1", + "mode": "checkpoint", + "checkpoints": ["WD-DA-PKG-001"], + "reason": "live-recheck", + } + ] + assert plan["config"]["revalidation"]["requiredStepIds"] == ["DA1.1"] + assert plan["config"]["status"] == "in-progress" + + refreshed = store.update_row("DA1.1", checkpoint_result="Passed") + assert "revalidation" not in refreshed + + +def test_scope_change_regresses_manual_evidence_and_dependents(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + store.update_row("DA1.1", checkpoint_result="Passed") + config = _config(tmp_path) + config["tenant"] = "tenant-one" + state_module._atomic_write_text( + store.config_path, json.dumps(config, indent=2) + "\n" + ) + store.update_row( + "DA2.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence=_evidence(), + ) + config = _config(tmp_path) + config["tenant"] = "tenant-two" + config["setupStatus"]["DA5.1"].update( + { + "state": "done", + "verifiedBy": "attested", + "evidence": { + **_evidence("Final scenario passed."), + "scopeFingerprint": "0" * 64, + }, + } + ) + state_module._atomic_write_text( + store.config_path, json.dumps(config, indent=2) + "\n" + ) + + plan = store.revalidation_plan() + + stale = { + action["stepId"] + for action in plan["actions"] + if action["mode"] == "manual-evidence-stale" + } + assert "DA2.1" in stale + assert plan["config"]["setupStatus"]["DA2.1"]["state"] == "in-progress" + assert plan["config"]["setupStatus"]["DA5.1"]["state"] == "in-progress" + assert plan["config"]["status"] == "in-progress" + + +def test_unchanged_manual_scope_preserves_completion(tmp_path) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + store.update_row("DA1.1", checkpoint_result="Passed") + store.update_row( + "DA2.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence=_evidence(), + ) + + plan = store.revalidation_plan() + + assert not any( + action["stepId"] == "DA2.1" + and action["mode"] == "manual-evidence-stale" + for action in plan["actions"] + ) + assert plan["config"]["setupStatus"]["DA2.1"]["state"] == "done" + + +def test_final_readiness_requires_structured_current_scenario_evidence( + tmp_path, +) -> None: + store = WorkdayDAStateStore(tmp_path) + store.initialize() + prerequisite_ids = [ + step_id + for step_id in store.step_by_id + if step_id != "DA5.1" + ] + _mark_done(store, *prerequisite_ids) + + with pytest.raises( + WorkdayDAStateError, + match="structured scenario record", + ): + store.update_row( + "DA5.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence=_evidence("Generic acknowledgement is insufficient."), + ) + + with pytest.raises( + WorkdayDAStateError, + match="shared non-maker test employee", + ): + store.update_row( + "DA5.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence={ + **_evidence("A maker-only test is insufficient."), + "scenario": { + "scenarioName": "vacation balance", + "testUserCategory": "maker", + "nonMakerTestUserConfirmed": False, + "agentSharedWithTestUser": True, + "signedInUserConfirmed": True, + "realWorkdayDataConfirmed": True, + "connectionPromptObserved": False, + "unexpectedSignIn": False, + "completedAt": "2026-09-25T12:00:00Z", + }, + }, + ) + + ready = store.update_row( + "DA5.1", + checkpoint_result="Manual", + result_source="user-acknowledgement", + ack=True, + evidence={ + **_evidence( + "Signed-in employee scenario returned real Workday data." + ), + "scenario": { + "scenarioName": "vacation balance", + "testUserCategory": "non-maker assigned test employee", + "nonMakerTestUserConfirmed": True, + "agentSharedWithTestUser": True, + "signedInUserConfirmed": True, + "realWorkdayDataConfirmed": True, + "connectionPromptObserved": False, + "unexpectedSignIn": False, + "completedAt": "2026-09-25T12:00:00Z", + }, + }, + ) + + assert ready["setupStatus"]["DA5.1"]["state"] == "done" + assert ready["status"] == "ready" + scenario = ready["setupStatus"]["DA5.1"]["evidence"]["scenario"] + assert scenario["realWorkdayDataConfirmed"] is True + assert len( + ready["setupStatus"]["DA5.1"]["evidence"]["scopeFingerprint"] + ) == 64 diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 3213b4466..fa2b95f40 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -382,7 +382,19 @@ def test_workday_da_setup_routes_only_supported_hr_agents() -> None: assert "Workday integration with the ESS IT Agent isn't supported" in step1 assert "or run `WD-PKG-001`" in normalized_step1 assert "src/skills/foundation-setup/SKILL.md" not in step1 + assert "Workday setup path selected" in step1 + assert "Microsoft Entra ID Integrated" in step1 + assert "git branch --show-current" not in step1 + assert "Workspace revision:" not in step1 + assert "Setup state:" not in step1 + assert "either architecture's own completion contract" in step1 + assert '.local/connect/workday-da/config.json` has `status: "ready"' in step1 + assert "completion.requiredStepIds" in step1 + assert "Never use shared DA provider state to label an ESS DA IT" in step1 assert _WORKDAY.is_file() + assert "not** the entry point for `/connect workday`" in workday + assert "src/skills/connect/SKILL.md" in workday + assert "do not show the hybrid-unavailable message" in workday assert "Hybrid Workday extension setup is not available" in workday assert "Do not run the retained Workday setup playbooks" in workday diff --git a/tests/setup/test_workday_command_discovery.py b/tests/setup/test_workday_command_discovery.py index 274fbb1bb..981cd8e7e 100644 --- a/tests/setup/test_workday_command_discovery.py +++ b/tests/setup/test_workday_command_discovery.py @@ -60,3 +60,19 @@ def test_connect_workday_bypasses_runtime_readiness_gate() -> None: assert "typed `/connect` or `/connect-workday`" in normalized assert "even when runtime `connect_ready` is false" in normalized + + +def test_connect_workday_instruction_routes_to_architecture_aware_connect() -> None: + instructions = ( + _SOLUTION / ".github" / "copilot-instructions.md" + ).read_text(encoding="utf-8") + normalized = _normalize(instructions) + + assert ( + "(`/connect workday` or `/connect-workday`) | " + "`src/skills/connect/SKILL.md`" + ) in normalized + assert ( + "Provision/connect the Workday setup environment (`/connect workday`) " + "| `src/skills/setup/SKILL.md`" + ) not in normalized diff --git a/tests/setup/test_workday_da_foundation.py b/tests/setup/test_workday_da_foundation.py index b147e0765..15c75966e 100644 --- a/tests/setup/test_workday_da_foundation.py +++ b/tests/setup/test_workday_da_foundation.py @@ -49,14 +49,20 @@ def test_checklist_uses_readable_titles_without_visible_internal_ids() -> None: assert any("Match the signed-in employee" in line for line in visible_rows) -def test_orchestrator_renders_canonical_titles_in_canonical_order() -> None: - tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") +def test_orchestrator_renders_only_customer_milestones() -> None: skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") - canonical_titles = re.findall(r"^- \[ \] \*\*(.+?)\*\*", tasks, re.MULTILINE) - rendered_titles = re.findall(r"^\s+- \{m\} (.+)$", skill, re.MULTILINE) - - assert rendered_titles == canonical_titles + milestones = re.findall(r"^\s+\| ([A-Za-z ]+) \| \{marker\} \|$", skill, re.MULTILINE) + assert milestones == [ + "Preflight", + "Microsoft Entra", + "Workday administrator", + "Connections", + "Runtime configuration", + "Network readiness", + "Employee validation", + ] + assert "- {m} Install the Workday extension package" not in skill def test_state_contract_requires_immediate_durable_updates() -> None: @@ -65,7 +71,9 @@ def test_state_contract_requires_immediate_durable_updates() -> None: assert "A `MANUAL` or attestation-gated row is never" in updater assert "**Persist immediately — never batch.**" in updater + assert ".local/connect/workday-da/tasks.md" in updater assert ".local/setup/workday-da/tasks.md" in updater + assert "Never maintain both paths" in updater assert ".local/connect/workday-da/config.json" in updater assert "Read" in schema and "Merge" in schema and "Write" in schema assert "sidecarDataverseEndpoint" in schema @@ -73,6 +81,48 @@ def test_state_contract_requires_immediate_durable_updates() -> None: assert '"provenance"' in schema assert '`reviewed`' in updater assert "FAILED` or `ERROR` always produces `blocked`" in updater + assert "**What FlightCheck found**" in updater + assert "**What you need to verify**" in updater + assert '"question": "Have you completed this step' not in updater + assert '"recommended": true' not in updater + normalized_updater = " ".join(updater.split()) + assert ( + "Never infer acknowledgement from a FlightCheck pass, a bare `done`" + in normalized_updater + ) + + +def test_workday_skills_have_discovery_metadata_and_direct_navigation() -> None: + da_skill = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") + cea_skill = ( + _REPO_ROOT + / "solutions" + / "ess-maker-skills" + / "src" + / "skills" + / "connect" + / "workday" + / "SKILL.md" + ).read_text(encoding="utf-8") + + assert da_skill.startswith("---\nname: connect-workday-da\n") + assert "description: >-" in da_skill + assert "## Playbook map" in da_skill + assert "checked-in playbooks and the executable definition are the controlled" in da_skill + assert "Do not browse for, merge in, or improvise setup steps" in da_skill + for reference in ( + "install-extension.md", + "provision-entra-app.md", + "configure-tenant.md", + "configure-power-platform.md", + "verify-connection.md", + "shared/checklist-updater.md", + "shared/permission-gate.md", + "shared/config-schema.md", + "workday-da.definition.json", + ): + assert f"]({reference})" in da_skill + assert cea_skill.startswith("---\nname: connect-workday\n") def test_entra_setup_pins_tenant_and_exact_app_identity() -> None: @@ -90,29 +140,31 @@ def test_entra_setup_pins_tenant_and_exact_app_identity() -> None: assert "Never downgrade a programmatic privileged-role" in gate assert "user_impersonation" in entra assert "claimsMappingPolicy" in entra + assert "repeat both in chat as copyable text" in entra + assert ( + "Never require the user to copy a URL or code from the inline terminal" + in entra + ) def test_workday_tenant_setup_preserves_manual_gates_and_safe_order() -> None: tasks = (_WORKDAY_DA / "tasks.md").read_text(encoding="utf-8") tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + normalized = " ".join(tenant.split()) - register = tenant.index("## DA3.1 + DA3.2 — Register the API client") - policy = tenant.index("## DA3.3 — Verify the signed-in employee authentication policy") + safety = tenant.index("## DA3.0b — Protect the active federation") + packet = tenant.index("## DA3.1–DA3.4 — Workday administrator work packet") - assert register < policy - assert "Single-tenant SAML pre-gate" in tenant + assert safety < packet + assert "active SAML identity provider" in tenant assert "CHECKPOINT_RESULT=\"MANUAL\"" in tenant assert "ACK=true" in tenant - assert "Workday cert field is not API-reachable" in tenant assert "checkpoints: WD-CONN-102 | gate: manual" in tasks assert "checkpoints: WD-API-CLIENT-001 | gate: attest" in tasks - assert ( - "| DA3.2 | `WD-API-CLIENT-001` — Workday connection fields captured" - in tenant - ) - assert "There is no separate domain-to-integration-security-group" in tenant - assert "Do not look for an OAuth-client restriction" in tenant + assert "Integration System Security Group" in tenant + assert "Do not use an ISU" in tenant assert "Existing active policy already allows employee SAML" in tenant + assert "will not perform or guide that replacement" in normalized def test_workday_portal_tasks_start_only_after_the_admin_gate() -> None: @@ -126,6 +178,6 @@ def test_workday_portal_tasks_start_only_after_the_admin_gate() -> None: in normalized_entra ) assert "Do not ask the maker to open Workday" in entra - assert tenant.index("## DA3.0 — Workday administrator gate") < tenant.index( - "## DA3.0b — Single-tenant SAML pre-gate" + assert tenant.index("## DA3.0 — Confirm the administrator is available") < tenant.index( + "## DA3.0b — Protect the active federation" ) diff --git a/tests/setup/test_workday_da_orchestration.py b/tests/setup/test_workday_da_orchestration.py index 533360dc7..647fc7643 100644 --- a/tests/setup/test_workday_da_orchestration.py +++ b/tests/setup/test_workday_da_orchestration.py @@ -16,19 +16,41 @@ / "setup" / "workday-da" ) +_CONNECT = _WORKDAY_DA.parents[1] / "connect" def test_orchestrator_resumes_durable_state_without_restarting_setup() -> None: text = (_WORKDAY_DA / "SKILL.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert "pick the first whose state is not `done`" in text + assert "pick the first whose state is not" in normalized assert "must not** batch those writes" in text assert 'provider `status` to be `"ready"`' in text + assert ".local/connect/workday-da/tasks.md" in text + assert ".local/setup/workday-da/tasks.md" in text + assert "move that exact file to the canonical path" in normalized assert "you do not need to run `/setup` again" in normalized - assert "Here's the plan for connecting Workday to your ESS HR agent" in text - assert "- {m} Verify employee SAML sign-in policy" in text + assert "customer-status" in text + assert "Workday connection progress:" in text + assert "| Preflight | {marker} |" in text + assert "| Employee validation | {marker} |" in text + assert "not the 21 internal rows" in normalized + assert "Do not repeat that sentence" in text assert "Your ESS HR agent is connected to Workday" in text + assert "supports only the Workday connection option named **Microsoft" in text + assert "direct Workday federation through Okta, Ping" in text + assert "do not expose Git revisions or local file-system paths" in normalized + assert "same five-phase lifecycle and the same completion gates" in text + assert "Environment type never skips, reorders, or relaxes" in normalized + + +def test_router_keeps_internal_diagnostics_out_of_customer_message() -> None: + route = (_CONNECT / "step1.md").read_text(encoding="utf-8") + + assert "Workday setup path selected:" in route + assert "Workspace revision:" not in route + assert "Setup state:" not in route + assert "This release does not configure direct Workday federation" not in route def test_extension_install_uses_the_ring_aware_runtime_installer() -> None: @@ -43,6 +65,93 @@ def test_extension_install_uses_the_ring_aware_runtime_installer() -> None: assert "do not restart the Workday checklist" in normalized +def test_extension_install_guides_missing_dataverse_provisioning() -> None: + text = (_WORKDAY_DA / "install-extension.md").read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert "doesn't have a Dataverse database yet" in text + assert "https://admin.powerplatform.microsoft.com/" in text + assert "**Add Dataverse** or **Add database**" in text + assert "solution, connection references, and cloud flows" in normalized + assert "refresh the environment inventory" in normalized + assert "rather than trusting acknowledgement alone" in normalized + assert "do not run the package checkpoint or installer" in normalized + assert "Power Platform administrator" in normalized + + +def test_entra_checks_reuse_the_verified_admin_account() -> None: + text = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") + tenant = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + + assert "az account show --query user.name -o tsv" in text + assert "ENTRA_ADMIN_ACCOUNT" in text + assert "entraAdminAccount = ENTRA_ADMIN_ACCOUNT" in text + assert text.count('--preferred-username "{ENTRA_ADMIN_ACCOUNT}"') == 7 + assert "manual-only FlightCheck checkpoints" in tenant + + +def test_entra_changes_use_one_scoped_customer_approval() -> None: + text = (_WORKDAY_DA / "provision-entra-app.md").read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert "## DA2.0c — Approve the Microsoft Entra change plan" in text + assert "one scoped plan covering all remaining Microsoft Entra work" in normalized + assert "Do not ask for separate approval" in text + assert "If the resolved tenant or application changes" in normalized + + +def test_workday_security_changes_are_explicitly_manual_admin_actions() -> None: + text = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert "Before I change any Workday security settings" not in text + assert "The skill will not sign in to Workday" in text + assert "normal organization-approved Workday account" in normalized + assert "must never request or collect a Workday administrator's password" in normalized + assert "do not ask a second question that repeats the classification" in normalized + assert "Complete this Workday administrator checklist" in text + + +def test_saml_safety_gate_pauses_replacement_outside_the_standard_flow() -> None: + text = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert "pause the standard setup" in normalized + assert "will not perform or guide that replacement" in normalized + assert "normal change process" in normalized + assert "Stop without presenting the remaining Workday changes" in text + assert "I approve replacement" not in text + + +def test_saml_precheck_requests_only_the_minimum_provider_information() -> None: + text = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + precheck = text[ + text.index("## DA3.0b — Protect the active federation"): + text.index("## DA3.1–DA3.4 — Workday administrator work packet") + ] + + assert "No Identity Provider is enabled" in precheck + assert "Provide" in precheck and "**Service Provider ID**" in precheck + assert "A different provider is enabled, or the administrator is not sure" in precheck + assert "x509 Certificate" not in precheck + assert "Valid From" not in precheck + assert "Valid To" not in precheck + assert "do not ask a second question" in " ".join(precheck.lower().split()) + + +def test_workday_admin_actions_are_returned_in_one_structured_handoff() -> None: + text = (_WORKDAY_DA / "configure-tenant.md").read_text(encoding="utf-8") + + assert "Complete this Workday administrator checklist" in text + assert text.count("Use one structured `vscode_askQuestions` form") == 1 + assert '"header": "Signing certificate"' in text + assert '"header": "Tenant security"' in text + assert '"header": "API client ID"' in text + assert '"header": "Token endpoint"' in text + assert '"header": "Employee SAML policy"' in text + assert "Do not run manual-only FlightCheck checkpoints" in text + + def test_connections_are_created_before_binding_and_flow_activation() -> None: text = (_WORKDAY_DA / "configure-power-platform.md").read_text( encoding="utf-8" @@ -57,12 +166,45 @@ def test_connections_are_created_before_binding_and_flow_activation() -> None: assert "Workday and Dataverse connections show **Connected**" in text assert "msdyn_sharedworkdaysoap_workdayruntime" in text assert "msdyn_sharedcommondataserviceforapps_workdayruntime" in text + assert "bind_workday_da_connections.py" in text + assert "WORKDAY_DA_BINDING_PLAN_JSON" in text + assert "WORKDAY_DA_BINDING_APPLIED_JSON" in text + assert "activate_workday_da_flows.py" in text + assert "WORKDAY_DA_FLOW_ACTIVATION_PLAN_JSON" in text + assert "WORKDAY_DA_FLOWS_ACTIVATED_JSON" in text + assert '"verified": true' in text + assert "2. Enter the connection fields in the order shown below." in text + assert "Workday tenant: `{tenant}`" in text + assert "| Microsoft Entra resource URL | `http://www.workday.com/{tenant}` |" in text + assert "Newly installed managed-solution flows" in text + assert "Do not open the agent's Connection settings yet" in text + assert "## DA4.2a — Approve the remaining runtime changes" in text + assert "approves all remaining helper operations" in text + assert text.count("another approval") + text.count("a second approval") >= 3 + + +def test_network_review_is_conditional_and_non_blocking() -> None: + text = (_WORKDAY_DA / "configure-power-platform.md").read_text( + encoding="utf-8" + ) + definition = ( + _WORKDAY_DA / "workday-da.definition.json" + ).read_text(encoding="utf-8") + normalized = " ".join(text.split()) + + assert "## DA4.8 — Review network restrictions" in text + assert "Most environments need no separate action" in text + assert "This is a non-blocking advisory" in text + assert "do not block setup solely" in normalized + assert '"id": "DA4.8"' in definition + assert '"gate": "advisory"' in definition def test_topic_and_authorization_guidance_matches_the_supported_runtime() -> None: text = (_WORKDAY_DA / "configure-power-platform.md").read_text( encoding="utf-8" ) + normalized = " ".join(text.split()) assert "Enable all Workday topics" in text assert "Choose specific Workday topics" in text @@ -70,13 +212,34 @@ def test_topic_and_authorization_guidance_matches_the_supported_runtime() -> Non assert "Prefer: return=representation" in text assert "fails closed on these" in text assert "Do not rely on the script's exit code" not in text + assert "Both invocations below occur after every Workday flow is connected" in normalized + assert "The two invocations are preview and apply" in normalized + assert "AppSource installer installs the managed package but does not activate" in normalized + assert "Never continue to agent Connection settings" in normalized + assert "Do not edit, rename, delete, or" in text + assert "other package-managed system topics" in text + assert "Workday [System] - 1: Set User Context V2" in text + assert "Workday System Get CommonExecution" in text + assert "package/version drift" in text + assert "configure_workday_da_user_context.py" in text + assert "WORKDAY_DA_USER_CONTEXT_PLAN_JSON" in text + assert "WORKDAY_DA_USER_CONTEXT_APPLIED_JSON" in text + assert "[Admin] - User Context - Setup" in text + assert "select the existing topic reference" in text + assert "WD-DA-CTX-001" in text + assert "WD-DA-CONN-001" in text + assert "who is not the maker" in text + assert "Do not use a write or approval scenario" in normalized def test_readiness_requires_a_signed_in_runtime_scenario() -> None: text = (_WORKDAY_DA / "verify-connection.md").read_text(encoding="utf-8") normalized = " ".join(text.split()) - assert "Run one enabled Workday scenario" in text + assert "Run one enabled, read-only Workday scenario" in normalized assert "returns real Workday data" in normalized assert 'status: "ready"' in text assert "does not prove the live" in text + assert "who is not the maker" in text + assert "agentSharedWithTestUser" in text + assert '"connectionPromptObserved": false' in text