From 645682ea38e872ec3b04f09ee632d3db879fa15e Mon Sep 17 00:00:00 2001 From: Goutham Surendra Ichapuram Date: Tue, 15 Sep 2026 00:16:19 -0700 Subject: [PATCH 01/17] Add generic connect-lifecycle engine (provider contract + runner), Workday as reference provider - src/skills/connect/shared/lifecycle-contract-schema.md: canonical JSON contract shape a provider supplies (phases, checkpoint gates, role gates, action fragments for mutating steps, pending-scoped-profile annotations). - src/skills/connect/shared/lifecycle-runner.md: the single provider-agnostic routine that shows the plan, collects attestation, live-re-verifies any previously-done phase before trusting it, runs each phase's FlightCheck checkpoints, renders results via the existing checklist-updater.md U.0/U.0a routine, and gates mutating phases through the existing permission-gate.md. Contains zero Workday-specific (or any provider-specific) logic. - src/skills/connect/workday/contract.json: Workday's contract - discovery (WD-PKG-001, DV-CONN-001, WD-CONN-012), agent-wiring (WD-REST-002, a programmatic Environment Maker gate reusing the exact Dataverse security-role query already proven in setup/workday/install-workday-extension-pack.md P5.0), validation (WD-RUN-001). Not-yet-available FlightCheck scoped connect profiles (ADO 7865450) are recorded as a pending annotation only - never fabricated as a passing result. - src/skills/connect/workday/actions/wire-user-context-redirect.md: the one bespoke mutation this provider needs - wiring the agent's User Context topic redirect, with checkpoint/scan/dry-run/push discipline. - src/skills/connect/workday/SKILL.md: thin entry point that hands off to the generic runner. - src/skills/connect/step1.md + SKILL.md: Workday routing now checks whether an extension is already installed anywhere in the environment (live WD-PKG-001 check) before deciding between this lightweight lifecycle and the full setup orchestrator; an already-completed lifecycle resumes through the same live re-verification path rather than a separate fast-path check. A future ISV (ServiceNow, SuccessFactors, ...) reuses this same runner by authoring its own contract.json + action fragments - no changes to the runner itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/skills/connect/SKILL.md | 27 ++- .../shared/lifecycle-contract-schema.md | 144 ++++++++++++ .../skills/connect/shared/lifecycle-runner.md | 207 ++++++++++++++++++ .../src/skills/connect/step1.md | 42 +++- .../src/skills/connect/workday/SKILL.md | 31 +++ .../actions/wire-user-context-redirect.md | 89 ++++++++ .../src/skills/connect/workday/contract.json | 41 ++++ 7 files changed, 570 insertions(+), 11 deletions(-) create mode 100644 solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md create mode 100644 solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md create mode 100644 solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md create mode 100644 solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md create mode 100644 solutions/ess-maker-skills/src/skills/connect/workday/contract.json diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 7de1b88cc..7ec8de32d 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -19,8 +19,9 @@ pass it to step1 as PRE_SELECTED_INTEGRATION. Step1 will skip the Read `src/skills/connect/step1.md` and follow it. (Step 1 asks which integration, detects existing state, and dispatches — -ServiceNow to its own step files and Workday to the hybrid-extension boundary -in `src/skills/setup/SKILL.md`.) +ServiceNow to its own step files; Workday to either the lightweight +already-installed lifecycle or the full setup orchestrator, depending on +what's already in the environment.) --- @@ -43,10 +44,24 @@ Workday delegates to the setup orchestrator: - Step 3 (Basic): `step3-basic.md` — install extension pack (Basic fields) - Step 4: `step4.md` — verify connection -- **Workday**: routes to `src/skills/setup/SKILL.md`, which reports that the - separately owned hybrid-extension setup contract is not yet available. It - does not execute the retained legacy Workday playbooks or any retired - foundation setup operation. +- **Workday**: two paths, chosen automatically by `step1.md`: + - **Extension already installed elsewhere** — a lightweight lifecycle that + confirms the extension and its connections, wires this agent's Workday + topics, and validates the connection end to end: + `src/skills/connect/workday/SKILL.md`, driven by the generic + `src/skills/connect/shared/lifecycle-runner.md` against + `src/skills/connect/workday/contract.json`. State: + `.local/connect/workday/lifecycle.json`. This same runner + contract + pattern is what a future integration reuses — a new ISV only needs its + own contract file and action fragments, not a new runner. + - **Nothing installed yet** — the full **setup orchestrator** + (`src/skills/setup/SKILL.md`), which provisions the Power Platform + environment, installs the ESS base agent, provisions the Entra app, + configures the Workday tenant, installs the extension pack, and verifies + the connection. It is resume-aware: if setup was already started it picks + up at the first unverified step, and it fast-forwards steps that are + already done. State: `setupStatus` in `.local/connect/workday/config.json` + + `.local/setup/workday/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/shared/lifecycle-contract-schema.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md new file mode 100644 index 000000000..95d8cfeec --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md @@ -0,0 +1,144 @@ +# Connect Lifecycle — Provider Contract Schema (Shared) + +This file documents the **canonical shape** of a provider's connect-lifecycle +contract, read by the generic runner (`lifecycle-runner.md`). It is a +*reference doc*, not an executable fragment — there are no Message blocks here. + +A **provider contract** is what makes the runner reusable across integrations: +Workday, ServiceNow, SuccessFactors, or any future ISV each supply their own +contract file plus their own action fragments for steps that change the +agent. The runner itself contains no integration-specific logic — it only +reads a contract, runs the checkpoints it names, and renders results. + +**Canonical contract file:** `src/skills/connect/{provider}/contract.json` +**Canonical state file (per agent):** `.local/connect/{provider}/lifecycle.json` + +--- + +## Contract fields + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `provider` | string | yes | Short key, lower-case, no spaces (e.g. `"workday"`). Must match the folder name under `src/skills/connect/{provider}/` and the state folder under `.local/connect/{provider}/`. | +| `displayName` | string | yes | Human name shown to the user (e.g. `"Workday"`). | +| `detect` | object | yes | How the runner's caller decides this lifecycle applies at all — see "Detect block" below. | +| `phases` | array | yes | Ordered list of phase objects — see "Phase fields" below. Executed strictly in array order; a phase never starts until every phase before it is `done` (or `skipped`, see below). | +| `pendingScopedProfile` | object | no | Documents a FlightCheck capability this contract is standing in for until it ships (see "Pending scoped profiles" below). Never used to fabricate a result — informational only. | + +### Detect block + +```json +"detect": { + "checkpoint": "WD-PKG-001", + "meansInstalled": "Passed" +} +``` + +- `checkpoint` — a FlightCheck checkpoint ID whose result tells the caller + whether the external package/extension already exists in the environment. +- `meansInstalled` — the `status` value (from `flightcheck/runner.Status`) + that counts as "installed." Any other status (including `Failed` or + `NotConfigured`) means "not installed" — the caller should not invoke this + lifecycle and should fall back to its own from-scratch setup flow. + +### Phase fields + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `id` | string | yes | Stable, internal only — never shown to the user. | +| `label` | string | yes | Plain-language description shown in the up-front plan and the resume checklist (e.g. `"Confirm the Workday extension and its connections are healthy"`). No internal IDs, checkpoint names, or file paths. | +| `checkpoints` | array of strings | yes | One or more FlightCheck checkpoint IDs (or family wildcards, e.g. `"WD-FLOW-*"`) that gate this phase. The phase is `done` only when every listed checkpoint reports `Passed`, `Manual` (after attestation), `NotConfigured`, or `Skipped` — see "Phase status rules" below. | +| `mutates` | boolean | no (default `false`) | `true` if completing this phase changes the live agent (edits a file, pushes a change). Drives the role gate below. | +| `requiredRole` | string | required when `mutates` is `true` | Human-readable role name passed to `permission-gate.md` as `REQUIRED_ROLE` before the phase's action runs. | +| `gateMode` | string | no (default `"attested"`) | `"programmatic"` or `"attested"` — passed to `permission-gate.md` as `GATE_MODE`. Use `"programmatic"` only when `roleQuery` names a real, working query. | +| `roleQuery` | array of strings | required when `gateMode` is `"programmatic"` | The exact command(s) `permission-gate.md` runs as `ROLE_QUERY`, and the role name(s) that count as a pass. Copy an existing, already-proven query rather than inventing a new one (e.g. the Dataverse security-role check `src/skills/setup/workday/install-workday-extension-pack.md` section P5.0 uses for "Environment Maker"). | +| `roleQueryPassNames` | array of strings | required when `gateMode` is `"programmatic"` | Role names in the query's result that count as holding `requiredRole` (include the role itself and any role that supersedes it, e.g. `System Administrator`). | +| `actionDoc` | string (path) | required when `mutates` is `true` | Path to a provider-owned markdown fragment containing the bespoke steps needed to make the phase's checkpoint(s) pass (e.g. editing a topic file and pushing it). The runner reads and follows this file; it contains its own Message blocks and is written by the provider, not the runner. | +| `rollbackLabel` | string | no | Passed to `scripts/checkpoint.py` before a mutating action runs, so the operator has a named restore point. | + +### Pending scoped profiles + +FlightCheck may later ship purpose-built "connect lifecycle" scoped profiles +(tracked separately) that replace a phase's individual checkpoint list with +one pre-composed check. Until a profile exists, a contract lists the +individual checkpoints that cover the same ground today. Record the target +profile as documentation only: + +```json +"pendingScopedProfile": { + "note": "FlightCheck scoped connect profiles are proposed but not yet available; this contract runs the equivalent individual checkpoints until they ship.", + "tracking": "ADO 7865450" +} +``` + +Never mark a phase `done` because a scoped profile is "assumed" to pass — only +a real checkpoint run (or a real, attested manual step) advances a phase. + +--- + +## Phase status rules + +Reuses the exact `Status` values `flightcheck/runner.py` already defines — +this schema does not invent a parallel status vocabulary: + +| Checkpoint status | Phase effect | +|---|---| +| `Passed` | Counts toward `done`. | +| `Manual` | Counts toward `done` only after the user explicitly attests (same rule as `checklist-updater.md`'s U.2 — a `Manual` result is never auto-completed). | +| `NotConfigured` / `Skipped` | Counts toward `done` — nothing to fix, does not block. | +| `Warning` | Counts toward `done` only after the user is shown the warning and chooses to continue (never silently ignored). | +| `Failed` / `Error` | Blocks the phase. The runner shows the remediation and stops advancing; the user retries after fixing it, or exits and resumes later. | + +A phase is `done` only when **every** checkpoint it lists resolves to one of +the "counts toward done" outcomes above. Partial results leave the phase +`in-progress`. + +--- + +## State file shape + +```json +{ + "provider": "workday", + "attested": true, + "attestedAt": "2026-09-15T14:02:00Z", + "currentPhase": "agent-wiring", + "phases": { + "discovery": { + "status": "done", + "lastVerifiedAt": "2026-09-15T14:03:10Z", + "checkpointResults": { "WD-PKG-001": "Passed", "DV-CONN-001": "Passed", "WD-CONN-012": "Passed" } + }, + "agent-wiring": { + "status": "pending", + "actionApplied": false, + "checkpointResults": {} + }, + "validation": { "status": "pending", "checkpointResults": {} } + } +} +``` + +- `attested` — `true` once the user has seen the up-front plan (phase labels + + required roles) and agreed to proceed. Set once; never reset by a resume. +- `phases.{id}.status` ∈ `pending` \| `in-progress` \| `done` \| `blocked`. +- `phases.{id}.lastVerifiedAt` — timestamp of the most recent **live** + checkpoint run that produced the current `status`. The runner never trusts + a `done` status without a `lastVerifiedAt` from *this* resume — see + "Live re-verification on resume" in `lifecycle-runner.md`. +- `phases.{id}.actionApplied` — mutating phases only; `true` once the + action doc has been followed at least once (so a resume doesn't re-apply an + idempotent-unsafe action; it re-verifies instead). + +--- + +## Round-trip contract + +Any file that touches the state file must: + +1. **Read** the existing file first (it may hold progress from an earlier + turn or an earlier `/connect` session). +2. **Merge** — update only the phase(s) it just ran; never drop other phases' + recorded state. +3. **Write** the merged object back immediately (not batched) — the same + durability rule `checklist-updater.md` applies to the setup checklist. diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md new file mode 100644 index 000000000..726596a2d --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md @@ -0,0 +1,207 @@ +# Connect Lifecycle Runner (Shared) + +The single, provider-agnostic routine that drives **any** integration's +connect lifecycle from its contract file. A provider (Workday, ServiceNow, any +future ISV) supplies a contract (`lifecycle-contract-schema.md`) plus its own +action fragments for mutating phases; this file contains zero +integration-specific logic. Adding a new provider never requires changing this +file — only authoring a new contract + action fragments. + +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 surface a checkpoint ID, phase ID, file +path, or the word "checkpoint"/"contract"/"state file" to the user. + +**Inputs from the calling file:** +- `PROVIDER` — the provider key (e.g. `"workday"`), matching a contract at + `src/skills/connect/{PROVIDER}/contract.json`. + +**Files:** +- **Contract (read-only):** `src/skills/connect/{PROVIDER}/contract.json` +- **State (read/write):** `.local/connect/{PROVIDER}/lifecycle.json` — shape + defined in `lifecycle-contract-schema.md`. + +--- + +## L.0 — Load the contract and state + +Read `src/skills/connect/{PROVIDER}/contract.json`. If it does not exist, +**stop and report** — the calling file named a provider with no contract. + +Read `.local/connect/{PROVIDER}/lifecycle.json`. If it does not exist, this is +a first run: initialize it in memory with `attested: false` and every phase +from the contract at `status: "pending"`, `checkpointResults: {}` — do not +write it to disk yet (write only after the plan is shown, in L.1). + +--- + +## L.1 — Show the plan and get attestation (first run only) + +Skip this section entirely if the state file already has `attested: true`. + +Build the plan from the contract, in phase order: +- One line per phase using its `label` verbatim. +- Collect the **union** of `requiredRole` across every `mutates: true` phase, + de-duplicated, in the order phases appear. + +**Message:** + +Here's what I'll do to connect this agent to {displayName}: + +{numbered list of phase labels, in contract order} + +This needs someone with the following access: {comma-separated required +roles, or omit this sentence entirely if no phase mutates}. + +I'll check as I go and stop to tell you if something needs attention. Ready +to start? + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Start connection", + "question": "Ready to start?", + "options": [ + { "label": "Yes, let's go", "recommended": true }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +**If "Not now":** Stop here. Do not write the state file — the next +invocation should show this same plan again. + +**If "Yes, let's go":** Write `.local/connect/{PROVIDER}/lifecycle.json` now +with `attested: true`, `attestedAt` = current UTC timestamp, and every phase +at `status: "pending"`. Continue to L.2. + +--- + +## L.2 — Live re-verification on resume + +**This is the load-bearing rule of this file.** A phase recorded `status: +"done"` in the state file from an earlier turn is a *cache*, not a fact. Never +report a phase complete, and never skip straight past it, without a live +checkpoint run from **this** invocation confirming it still holds. Environments +drift — a connection can be removed, a topic redirect can be reverted outside +this tool — and trusting a stale checkbox has already caused a real bug in +this family of skills once; do not reintroduce it. + +For every phase the state file marks `done`, in contract order, before doing +anything else: + +1. Re-run every checkpoint the phase lists (see L.4's checkpoint-running + steps — reuse that exact mechanism here, silently, without re-showing the + up-front plan). +2. If every checkpoint still resolves to a "counts toward done" outcome (per + `lifecycle-contract-schema.md`'s status table), update `lastVerifiedAt` and + leave the phase `done`. Do **not** re-render the U.0 table for a phase that + was already `done` and stays `done` on resume — only surface output for + phases that change state or that are not yet done. +3. If any checkpoint now resolves to `Failed`/`Error`, set that phase back to + `blocked`, and **every phase after it** back to `pending` (later phases may + have depended on this one still holding). Render the failure using L.4b's + rendering + blocked-message steps and stop — do not re-run L.4a's mutating + action just because a live re-check regressed (that would re-apply a + change without a fresh gate/rollback); a regression always needs a human + look, not an automatic retry. + +Once every previously-`done` phase is confirmed (or the loop stopped early on +a regression), continue to L.3. + +--- + +## L.3 — Find the current phase + +Walk the phases in contract order. The **current phase** is the first one +whose `status` is not `done`. + +- If every phase is `done`: go to L.5 (completion). +- Otherwise: set `currentPhase` to that phase's `id` and continue to L.4. + +--- + +## L.4 — Run the current phase + +### L.4a — Mutating phases: gate, then act + +If the current phase has `mutates: true` and `actionApplied` is not yet +`true` in its state entry: + +Apply `permission-gate.md` (from `src/skills/setup/shared/permission-gate.md`) +with `REQUIRED_ROLE` = the phase's `requiredRole`. Use the phase's `gateMode` +(default `"attested"` if the contract omits it); if `gateMode` is +`"programmatic"`, pass the phase's `roleQuery` as `ROLE_QUERY` verbatim, and +treat the query as a pass only if it returns one of `roleQueryPassNames` — the +runner never invents its own role query or pass condition. + +**If the gate returns `"stop"`:** stop here. Leave the phase `in-progress` in +the state file (write it now) so the next invocation resumes at this same +gate rather than re-showing the whole plan. + +**If the gate returns `"pass"`:** if the phase names a `rollbackLabel`, save a +checkpoint first: + +``` +python scripts/checkpoint.py "{rollbackLabel}" +``` + +Then read the phase's `actionDoc` file and follow it completely — it contains +its own Message blocks and tool calls. When it finishes, set +`phases.{id}.actionApplied = true` and write the state file immediately. + +### L.4b — Run the phase's checkpoints + +For each checkpoint ID the current phase lists, run: + +``` +python scripts/flightcheck/cli.py --checkpoint {ID} +``` + +After each run, render the result using the exact U.0 and U.0a routines from +`src/skills/setup/shared/checklist-updater.md` (read that file's U.0/U.0a +sections and apply them verbatim against `workspace/flightcheck/results.json` +— do not re-implement or paraphrase that rendering logic here). + +Aggregate the phase's outcome per the status table in +`lifecycle-contract-schema.md`: + +- **All checkpoints "count toward done"** (after any needed attestation for + `Manual`/`Warning` results — ask the same style of yes/no confirmation + `checklist-updater.md`'s U.2 uses when a result needs acknowledgement): + set `phases.{id}.status = "done"`, `lastVerifiedAt` = now, record each + checkpoint's status in `checkpointResults`. Write the state file. Return to + L.3 to advance to the next phase. +- **Any checkpoint `Failed`/`Error`:** set `phases.{id}.status = "blocked"`, + record `checkpointResults`. Write the state file. + + **Message:** + + I can't complete this step yet — {plain-language summary of what's + blocking it, drawn from the remediation text already shown above}. Fix + that, then tell me to continue and I'll pick up right here. + + **End message.** + + Stop. Do not attempt later phases. + +--- + +## L.5 — Completion + +Once every phase is `done`: + +**Message:** + +{displayName} is connected. Everything I checked is healthy. + +**End message.** + +Return control to the calling file (it may offer next steps, such as `/create` +for building topics — that offer belongs to the calling file, not here). diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index 00291147c..c749d94ac 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -11,9 +11,13 @@ Build a list of connected integrations (if any): - **ServiceNow** — connected if `.local/connect/servicenow/steps.md` exists and all items are checked. -- **Workday** — connected if `.local/connect/workday/config.json` exists and its - `setupStatus` shows every setup row (`S1.1` … `S6.2`) in state `done` (the - setup orchestrator owns this state). +- **Workday** — connected if either: + - `.local/connect/workday/config.json` exists and its `setupStatus` shows + every setup row (`S1.1` … `S6.2`) in state `done` (the setup orchestrator + ran a full install for this agent), or + - `.local/connect/workday/lifecycle.json` exists and every phase is + `done` (this agent was wired to an extension already installed + elsewhere — see 1.3 below). --- @@ -206,8 +210,36 @@ Now read `src/skills/connect/servicenow/step1.md` and follow it. ### If the user chose Workday (2 or "workday") -Now read `src/skills/setup/SKILL.md` and follow its hybrid-extension -availability boundary. Do not run the retained Workday playbooks directly. +**If `.local/connect/workday/lifecycle.json` already exists** (this agent was +previously wired to an already-installed extension via 1.3's second path +below): read `src/skills/connect/workday/SKILL.md` and follow it. It +re-verifies everything live before reporting "connected" — it never trusts +the saved state on its own. + +**Otherwise**, check whether a Workday extension already exists **anywhere in +this environment**, independent of whether this particular agent has been +set up against it yet: + +``` +python scripts/flightcheck/cli.py --checkpoint WD-PKG-001 +``` + +**If `Passed`:** a Workday extension is already installed in this +environment — this agent likely just needs to be wired to it, not a full +install. Read `src/skills/connect/workday/SKILL.md` and follow it; it shows +the user what it will check before doing anything, and only makes changes +once they confirm. + +**If anything other than `Passed`** (no extension installed yet, or its +package can't be confirmed): fall back to the full setup path. Workday +connection is then handled by the **setup orchestrator**, which provisions +the Power Platform environment, installs the ESS base agent, provisions the +Entra app, configures the Workday tenant, installs the extension pack, and +verifies the connection. It is resume-aware: if setup was already started it +picks up at the first unverified step, and it fast-forwards steps that are +already done. + +Read `src/skills/setup/SKILL.md` and follow it. ### If the user said something else diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md new file mode 100644 index 000000000..d2ac8675b --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md @@ -0,0 +1,31 @@ +# Connect Workday (already installed) + +Entry point for connecting this agent to a Workday extension that is +**already installed** somewhere in this environment. If no Workday extension +exists yet, the caller (`src/skills/connect/step1.md`) routes to the setup +orchestrator (`src/skills/setup/SKILL.md`) instead — this file is never +reached in that case. + +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. + +--- + +## W.1 — Run the lifecycle + +Read `src/skills/connect/shared/lifecycle-runner.md` and follow it with +`PROVIDER = "workday"`. + +That file loads `src/skills/connect/workday/contract.json`, resumes any +in-progress state from `.local/connect/workday/lifecycle.json`, shows the +plan and collects attestation on a first run, live-re-verifies anything +already recorded done, then runs whichever phase is next — confirming the +extension and its connections, wiring the agent's Workday topics, and +validating the end-to-end connection. + +--- + +## W.2 — After completion + +Once the lifecycle runner reports Workday connected, return control to the +caller. Do not repeat the runner's completion message or add your own. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md new file mode 100644 index 000000000..81a48e9e5 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md @@ -0,0 +1,89 @@ +# Action: Wire the User Context redirect to Workday + +Called by the lifecycle runner (`src/skills/connect/shared/lifecycle-runner.md`) +for the `agent-wiring` phase of the Workday contract. By the time this file +runs, the role gate has already passed and a rollback checkpoint has already +been saved — this file only performs the edit and pushes it. + +Every **Message** block is the exact text to show the user. Copy it verbatim. + +--- + +## A.0 — Role gate (Environment Maker) + +The lifecycle runner already applied `permission-gate.md` before reading this +file — see `src/skills/connect/shared/lifecycle-runner.md` section L.4a, which +uses `GATE_MODE = "programmatic"` with the same Dataverse security-role query +`src/skills/setup/workday/install-workday-extension-pack.md` section P5.0 +uses for this exact role. This file starts from a passed gate; it does not +re-check it. + +## A.1 — Explain what's about to change + +**Message:** + +I'll wire your agent's **User Context** topic to call Workday on every +conversation. Without this, Workday topics respond with "This feature isn't +available yet." + +**End message.** + +--- + +## A.2 — Resolve the installed system topic + +Find the installed Workday "Set User Context" system topic's dialog id under +`.local/agents/{slug}/topics/`. Use the actual installed topic name — do not +assume a fixed name, since it varies by install path (for example, +`WorkdaySystemGetUserContextV2` on the current extension pack). + +--- + +## A.3 — Edit the redirect + +Set the agent's `workspace/agents/{slug}/topics/user-context-setup.mcs.yml` +`OnRedirect` to a `BeginDialog` calling that dialog id: + +```yaml +kind: AdaptiveDialog +beginDialog: + kind: OnRedirect + id: main + priority: 0 + actions: + - kind: BeginDialog + id: bfT9Kx + displayName: Redirect to Workday System Get User Context + dialog: {USER_CONTEXT_DIALOG} +``` + +--- + +## A.4 — Scan, dry run, push + +Check for errors using the diagnostics tool on the edited file. Fix any +before continuing. + +Preview and push: + +``` +python scripts/push.py --dry-run +``` + +Review the preview. If it only shows the `user-context-setup` topic changing, +continue: + +``` +python scripts/push.py --yes +``` + +If the dry run shows anything unexpected changing, stop and report it instead +of pushing — do not push a change wider than this one topic edit. + +--- + +## A.5 — Return + +Return to the lifecycle runner. It re-runs `WD-REST-002` immediately after +this file completes and decides whether to advance or roll back — this file +does not re-run the checkpoint itself. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json new file mode 100644 index 000000000..d6d293423 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json @@ -0,0 +1,41 @@ +{ + "provider": "workday", + "displayName": "Workday", + "detect": { + "checkpoint": "WD-PKG-001", + "meansInstalled": "Passed" + }, + "phases": [ + { + "id": "discovery", + "label": "Confirm the Workday extension is installed and its connections are healthy", + "checkpoints": ["WD-PKG-001", "DV-CONN-001", "WD-CONN-012"], + "mutates": false + }, + { + "id": "agent-wiring", + "label": "Wire this agent's Workday topics so they can run", + "checkpoints": ["WD-REST-002"], + "mutates": true, + "requiredRole": "Environment Maker", + "gateMode": "programmatic", + "roleQuery": [ + "az rest --method GET --resource \"{ENV_URL}\" --url \"{ENV_URL}/api/data/v9.2/WhoAmI\" --query \"UserId\" -o tsv", + "az rest --method GET --resource \"{ENV_URL}\" --url \"{ENV_URL}/api/data/v9.2/systemusers%28{USER_ID}%29/systemuserroles_association?%24select=name\" --query \"value[].name\" -o json" + ], + "roleQueryPassNames": ["Environment Maker", "System Customizer", "System Administrator"], + "actionDoc": "src/skills/connect/workday/actions/wire-user-context-redirect.md", + "rollbackLabel": "Add User Context redirect to Workday" + }, + { + "id": "validation", + "label": "Confirm the end-to-end connection is healthy", + "checkpoints": ["WD-RUN-001"], + "mutates": false + } + ], + "pendingScopedProfile": { + "note": "FlightCheck scoped connect profiles (e.g. a single 'workday connect readiness' check standing in for WD-PKG-001 + DV-CONN-001 + WD-CONN-012) are proposed but not yet available; this contract runs the equivalent individual checkpoints until they ship.", + "tracking": "ADO 7865450" + } +} From 789c276858f3243d1b2c2de0ecc6d184f5fb306d Mon Sep 17 00:00:00 2001 From: Goutham Surendra Ichapuram Date: Tue, 15 Sep 2026 12:49:03 -0700 Subject: [PATCH 02/17] Remove internal ADO tracking metadata from shipped contract data pendingScopedProfile.tracking referenced an internal Azure DevOps work item ID inside contract.json / the schema reference doc - meaningless to anyone outside this team and unrelated to how the runner behaves. Dropped the whole unused pendingScopedProfile field (the runner never read it); kept the substantive engineering point - that a contract lists individual checkpoints today until FlightCheck ships a consolidated profile - as plain prose in the schema doc, with no internal ID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../shared/lifecycle-contract-schema.md | 26 +++++++------------ .../src/skills/connect/workday/contract.json | 6 +---- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md index 95d8cfeec..383fa901f 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md @@ -23,7 +23,6 @@ reads a contract, runs the checkpoints it names, and renders results. | `displayName` | string | yes | Human name shown to the user (e.g. `"Workday"`). | | `detect` | object | yes | How the runner's caller decides this lifecycle applies at all — see "Detect block" below. | | `phases` | array | yes | Ordered list of phase objects — see "Phase fields" below. Executed strictly in array order; a phase never starts until every phase before it is `done` (or `skipped`, see below). | -| `pendingScopedProfile` | object | no | Documents a FlightCheck capability this contract is standing in for until it ships (see "Pending scoped profiles" below). Never used to fabricate a result — informational only. | ### Detect block @@ -56,23 +55,18 @@ reads a contract, runs the checkpoints it names, and renders results. | `actionDoc` | string (path) | required when `mutates` is `true` | Path to a provider-owned markdown fragment containing the bespoke steps needed to make the phase's checkpoint(s) pass (e.g. editing a topic file and pushing it). The runner reads and follows this file; it contains its own Message blocks and is written by the provider, not the runner. | | `rollbackLabel` | string | no | Passed to `scripts/checkpoint.py` before a mutating action runs, so the operator has a named restore point. | -### Pending scoped profiles +### Evolving a phase's checkpoint list FlightCheck may later ship purpose-built "connect lifecycle" scoped profiles -(tracked separately) that replace a phase's individual checkpoint list with -one pre-composed check. Until a profile exists, a contract lists the -individual checkpoints that cover the same ground today. Record the target -profile as documentation only: - -```json -"pendingScopedProfile": { - "note": "FlightCheck scoped connect profiles are proposed but not yet available; this contract runs the equivalent individual checkpoints until they ship.", - "tracking": "ADO 7865450" -} -``` - -Never mark a phase `done` because a scoped profile is "assumed" to pass — only -a real checkpoint run (or a real, attested manual step) advances a phase. +that replace a phase's individual checkpoint list with one pre-composed +check. Until a profile exists, a contract simply lists the individual +checkpoints that cover the same ground today — there's no separate field for +this; it's just how `checkpoints` is populated in the meantime. When a +consolidated profile ships, update the phase's `checkpoints` list to use it. + +Never mark a phase `done` because a future profile is "assumed" to pass — +only a real checkpoint run (or a real, attested manual step) advances a +phase. --- diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json index d6d293423..555b31539 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json +++ b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json @@ -33,9 +33,5 @@ "checkpoints": ["WD-RUN-001"], "mutates": false } - ], - "pendingScopedProfile": { - "note": "FlightCheck scoped connect profiles (e.g. a single 'workday connect readiness' check standing in for WD-PKG-001 + DV-CONN-001 + WD-CONN-012) are proposed but not yet available; this contract runs the equivalent individual checkpoints until they ship.", - "tracking": "ADO 7865450" - } + ] } From 2f7f23ada6fe49d8c5b6541255d84474b8ef3ed5 Mon Sep 17 00:00:00 2001 From: Goutham Surendra Ichapuram Date: Wed, 16 Sep 2026 10:53:12 -0700 Subject: [PATCH 03/17] feat(connect): add Workday DA setup path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/flightcheck/checks/workday_da.py | 187 +++++ .../scripts/flightcheck/cli.py | 3 + .../scripts/flightcheck/registry.py | 20 + .../scripts/install_workday_da_extension.py | 201 ++++++ .../src/skills/connect/SKILL.md | 35 +- .../src/skills/connect/step1.md | 52 +- .../src/skills/connect/steps.md | 4 +- .../src/skills/setup/workday-da/SKILL.md | 181 +++++ .../setup/workday-da/configure-tenant.md | 406 +++++++++++ .../setup/workday-da/install-extension.md | 130 ++++ .../setup/workday-da/provision-entra-app.md | 644 ++++++++++++++++++ .../workday-da/shared/checklist-updater.md | 259 +++++++ .../setup/workday-da/shared/config-schema.md | 152 +++++ .../workday-da/shared/connection-fields.md | 143 ++++ .../workday-da/shared/permission-gate.md | 146 ++++ .../src/skills/setup/workday-da/tasks.md | 102 +++ .../setup/workday-da/verify-connection.md | 82 +++ tests/flightcheck/checks/test_workday_da.py | 249 +++++++ .../test_install_workday_da_extension.py | 168 +++++ 19 files changed, 3135 insertions(+), 29 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py create mode 100644 solutions/ess-maker-skills/scripts/install_workday_da_extension.py create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md create mode 100644 tests/flightcheck/checks/test_workday_da.py create mode 100644 tests/scripts/test_install_workday_da_extension.py diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py new file mode 100644 index 000000000..832645cee --- /dev/null +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -0,0 +1,187 @@ +# 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** +(DA) 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``. +""" + +from ..runner import CheckResult, Priority, Role, Status +from auth import query_all, AuthExpiredError # scripts/auth.py, on path via cli.py + + +# Parent (base agent) schema names for the two DA verticals that ship a +# Workday child package. The DA Hub bundle has no Workday child of its own — +# Workday is installed against the HR or IT vertical (see +# src/reference/solution-catalog.md, "## Parents" / "## Child packages"). +_DA_PARENT_SCHEMAS = { + "hr": "msdyn_copilotforemployeeselfservicedahr", + "it": "msdyn_copilotforemployeeselfservicedait", +} + +# The Workday child package schema for each DA vertical (from +# src/reference/solution-catalog.md, "## Child packages, connection +# references, and flows"). +_DA_WORKDAY_CHILD_SCHEMAS = { + "hr": "msdyn_essdahrworkday", + "it": "msdyn_essdaitworkday", +} + +_VERTICAL_LABEL = {"hr": "HR", "it": "IT"} + +_SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" + +# All four fixed schema names this check ever needs to resolve in one +# round-trip: the two DA parents plus their two Workday children. +_ALL_TRACKED_SCHEMAS = tuple(_DA_PARENT_SCHEMAS.values()) + tuple( + _DA_WORKDAY_CHILD_SCHEMAS.values() +) +_SOLN_FILTER = " or ".join( + f"uniquename eq '{schema}'" for schema in _ALL_TRACKED_SCHEMAS +) + +_DOC_LINK = ( + "https://learn.microsoft.com/en-us/microsoft-365/copilot/" + "employee-self-service/install" +) +_DESCRIPTION = "Workday extension package installed for the DA ESS agent" + + +def run_workday_da_checks(runner) -> list[CheckResult]: + """Emit the WD-DA-PKG-xxx checkpoints. + + 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. + """ + return _check_workday_da_package_installed(runner) + + +def _check_workday_da_package_installed(runner) -> list[CheckResult]: + """WD-DA-PKG-001: the DA Workday extension package is installed. + + Always emits exactly one CheckResult (principle 7 — bucket multi-resource + findings). Never raises — all errors are caught and turned into WARNING + results so a transient Dataverse failure does not abort the whole + flightcheck run. + """ + env_url = getattr(runner, "env_url", None) + token = getattr(runner, "dv_token", None) + + if not env_url or not token: + return [_result( + Status.SKIPPED.value, + "Dataverse URL or access token not available in this run.", + )] + + try: + all_solutions = query_all( + env_url, token, + "solutions", + _SOLN_SELECT, + _SOLN_FILTER, + ) + except AuthExpiredError as e: + return [_result( + Status.WARNING.value, + str(e), + remediation="Re-run FlightCheck to refresh the access token.", + )] + except Exception as e: + status_code = getattr(getattr(e, "response", None), "status_code", None) + status_hint = f" [HTTP {status_code}]" if status_code is not None else "" + return [_result( + Status.WARNING.value, + f"Unable to verify the DA Workday package: " + f"{type(e).__name__}{status_hint}: {e}", + remediation=( + "Inspect the error above; common causes are insufficient " + "Dataverse privileges on the solutions table (typically " + "surfaces as HTTP 403) or a transient platform error (HTTP 5xx)." + ), + )] + + installed_names = { + s.get("uniquename", "").casefold(): s for s in all_solutions + } + + installed_verticals = [ + vertical + for vertical, parent_schema in _DA_PARENT_SCHEMAS.items() + if parent_schema in installed_names + ] + + if not installed_verticals: + return [_result( + Status.FAILED.value, + "No DA Employee Self-Service base agent (HR or IT) was found in " + "this environment.", + remediation=( + "Run /setup to install the DA Employee Self-Service base " + "agent first, then run /connect workday again." + ), + )] + + findings = [] + missing_verticals = [] + for vertical in installed_verticals: + child_schema = _DA_WORKDAY_CHILD_SCHEMAS[vertical] + label = _VERTICAL_LABEL[vertical] + child = installed_names.get(child_schema) + if child: + findings.append(f"{label}: {_describe_solution(child)}") + else: + missing_verticals.append(vertical) + + if missing_verticals: + missing_labels = ", ".join(_VERTICAL_LABEL[v] for v in missing_verticals) + installed_summary = f" Already installed — {'; '.join(findings)}." if findings else "" + return [_result( + Status.FAILED.value, + f"The Workday extension package is not installed for the " + f"{missing_labels} edition of your DA Employee Self-Service " + f"agent.{installed_summary}", + remediation=( + "Open AppSource (or the Microsoft 365 admin center), find " + "the Workday extension for Employee Self-Service, and " + "deploy it to this environment — the same place you " + "installed the base agent. Wait for the install to finish, " + "then re-run this check." + ), + )] + + return [_result( + Status.PASSED.value, + f"DA Workday extension package installed: {'; '.join(findings)}.", + )] + + +def _result(status: str, result: str, remediation: str = "") -> CheckResult: + return CheckResult( + roles=[Role.ESS_MAKER.value], + checkpoint_id="WD-DA-PKG-001", + category="Workday DA", + priority=Priority.CRITICAL.value, + status=status, + description=_DESCRIPTION, + result=result, + remediation=remediation, + doc_link=_DOC_LINK, + ) + + +def _describe_solution(sol: dict) -> str: + """Render one solution row as ``uniquename (vX.Y.Z)`` for the result text.""" + name = sol.get("uniquename", "") + version = sol.get("version") + return f"{name} (v{version})" if version else name diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 31b2feeaa..669b64d44 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -71,6 +71,7 @@ from flightcheck.checks.graph_connector_kb import run_graph_connector_kb_checks from flightcheck.checks.agent_handoff import run_handoff_topic_checks from flightcheck.checks.workday import run_workday_checks +from flightcheck.checks.workday_da import run_workday_da_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 @@ -102,6 +103,7 @@ ("Workday", run_workday_checks), ("Workday Extension", run_workday_extension_checks), ], + "workdayda": [("Solution", run_solution_checks), ("Workday DA", run_workday_da_checks)], "topics": [("Workday Topics", run_topic_checks)], "graphconnector": [ ("External Systems", run_external_systems_checks), @@ -128,6 +130,7 @@ ("Workday Tenant", run_workday_tenant_checks), ("External Systems", run_external_systems_checks), ("Workday", run_workday_checks), + ("Workday DA", run_workday_da_checks), ("Workday Extension", run_workday_extension_checks), ("Workday Topics", run_topic_checks), ("Graph Connector KB", run_graph_connector_kb_checks), diff --git a/solutions/ess-maker-skills/scripts/flightcheck/registry.py b/solutions/ess-maker-skills/scripts/flightcheck/registry.py index 6040d49ff..dc759a9a1 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/registry.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/registry.py @@ -53,6 +53,7 @@ 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_tenant import run_workday_tenant_checks from flightcheck.checks.workday_extension import run_workday_extension_checks from flightcheck.checks.topics import run_topic_checks @@ -103,6 +104,7 @@ "Workday Tenant", "External Systems", "Workday", + "Workday DA", "Workday Extension", "Workday Topics", "Graph Connector KB", @@ -266,6 +268,23 @@ class ResolvedPlan: priority=Priority.CRITICAL.value, roles=(Role.ESS_MAKER.value,), ), + # ---- Workday DA: WD-DA-PKG-001 (setup/workday-da skill, step DA1.1) ---- + # 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. + CheckpointSpec( + key="WD-DA-PKG-001", + category_fn=run_workday_da_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,), + ), # ---- 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. @@ -632,6 +651,7 @@ class ResolvedPlan: "ENV-CAPACITY", "ESS-SOLN", "WD-PKG", + "WD-DA-PKG", "WD-CONN", "WD-RUN", "WD-FLOW", diff --git a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py new file mode 100644 index 000000000..6d998b594 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -0,0 +1,201 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Attempt an automated install of the DA Workday extension package. + +Used by ``src/skills/setup/workday-da/install-extension.md`` (step DA1.1). +Unlike ``install_ess_agent.py`` (the base ESS agent), the DA Workday +extension package has no entry in +``src/reference/ess-agent-installation/config.json`` — there is no confirmed +Marketplace catalog record for it. This script therefore does not assume the +package is installable through the Marketplace application API; it only +*tries*, using the same mechanism ``install_ess_agent.py`` uses for the base +agent (``PowerPlatformClient.list_environment_application_packages`` / +``install_application_package``), and reports a distinct, honest outcome — +``not-listed`` — when the tenant's Marketplace catalog does not return the +package at all. The calling skill step must treat ``not-listed`` as "hand off +to the manual AppSource install", never as a failure. + +This mirrors the principle already stated for the CEA extension pack install +(``src/skills/setup/workday/install-workday-extension-pack.md``): never infer +success from an unsupported or incomplete API response. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time + +from auth import discover_tenant +from flightcheck.checks.workday_da import ( + _DA_WORKDAY_CHILD_SCHEMAS as WORKDAY_DA_CHILD_SCHEMAS, +) +from flightcheck.powerplatform_client import PowerPlatformClient +from flightcheck.pp_admin_client import PPAdminClient + +# Reuse the base-agent installer's polling vocabulary and helpers rather than +# redefining them, so both scripts observe the Marketplace application API +# identically. +from install_ess_agent import ( # noqa: F401 (re-exported for callers/tests) + DEFAULT_TIMEOUT_SECONDS, + FAILED_STATES, + INSTALLED_STATES, + IN_PROGRESS_STATES, + InstallationTimeoutError, + _error_message, + _find_package, + _list_packages, + _wait_for_install, +) + +_VERTICAL_LABEL = {"hr": "HR", "it": "IT"} + + +class ExtensionNotListedError(RuntimeError): + """The DA Workday extension is not in this tenant's Marketplace catalog. + + Not a failure — the calling skill step must treat this as "automation is + not available here" and fall back to the manual AppSource install, the + same guidance ``WD-DA-PKG-001`` (``checks/workday_da.py``) already gives. + """ + + def __init__(self, unique_name: str): + self.unique_name = unique_name + super().__init__( + f"'{unique_name}' was not found in this tenant's Marketplace " + "application catalog." + ) + + +def install_workday_da_extension( + env_url: str, + vertical: str, + *, + pp_admin_client_factory=PPAdminClient, + powerplatform_client_factory=PowerPlatformClient, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + poll_interval_seconds: int = 20, + sleep=time.sleep, + clock=time.monotonic, + status_callback=lambda message: print(message, flush=True), + installation_state_callback=lambda _status: None, +) -> str: + """Try to install the DA Workday extension package for one vertical. + + Returns the installed child solution's schema name on success. Raises + ``ExtensionNotListedError`` when the Marketplace catalog does not return + the package at all (automation genuinely unavailable — not an error to + surface as a failure). Raises ``InstallationTimeoutError`` or + ``RuntimeError`` for other automation problems. + """ + env_url = env_url.rstrip("/") + schema_name = WORKDAY_DA_CHILD_SCHEMAS[vertical] + tenant_id = discover_tenant(env_url) + + pp_admin = pp_admin_client_factory(tenant_id) + pp_admin.authenticate(include_flow=False) + environment_id = pp_admin.find_environment_id_by_dataverse_url(env_url) + if not environment_id: + raise RuntimeError( + "Could not resolve the selected environment's Power Platform ID." + ) + + client = powerplatform_client_factory(tenant_id) + client.authenticate() + package = _find_package(_list_packages(client, environment_id), schema_name) + if not package: + raise ExtensionNotListedError(schema_name) + + unique_name = package.get("uniqueName") or schema_name + state = package.get("state") + if state in INSTALLED_STATES: + installation_state_callback("automatic-complete") + return schema_name + + if state in IN_PROGRESS_STATES: + installation_state_callback("installing") + else: + result = client.install_application_package(environment_id, unique_name) + if result.get("_error"): + raise RuntimeError( + "Your account cannot install Marketplace applications in this " + "environment. Use a Power Platform or Dynamics 365 " + "administrator account." + ) + last_state = result.get("lastOperation", {}).get("state") + if last_state in INSTALLED_STATES: + installation_state_callback("automatic-complete") + return schema_name + if last_state in FAILED_STATES: + raise RuntimeError( + _error_message(result.get("lastOperation", {}), "Installation failed.") + ) + installation_state_callback("installing") + + _wait_for_install( + client, + environment_id, + unique_name, + timeout_seconds=timeout_seconds, + poll_interval_seconds=poll_interval_seconds, + sleep=sleep, + clock=clock, + status_callback=status_callback, + ) + installation_state_callback("automatic-complete") + return schema_name + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Attempt an automated install of the DA Workday extension " + "package for one ESS vertical." + ) + ) + parser.add_argument("--url", required=True, help="Dataverse environment URL") + parser.add_argument( + "--vertical", + required=True, + choices=sorted(WORKDAY_DA_CHILD_SCHEMAS), + help="DA vertical: hr or it", + ) + args = parser.parse_args() + + base_result = { + "environmentUrl": args.url.rstrip("/"), + "vertical": args.vertical, + "schemaName": WORKDAY_DA_CHILD_SCHEMAS[args.vertical], + } + + try: + schema_name = install_workday_da_extension(args.url, args.vertical) + except ExtensionNotListedError as error: + print( + "WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:" + f"{json.dumps(base_result)}", + flush=True, + ) + print(f"INFO: {error}", file=sys.stderr) + sys.exit(3) + except InstallationTimeoutError as error: + result = {**base_result, "timeoutMinutes": error.timeout_seconds // 60} + print( + "WORKDAY_DA_EXTENSION_INSTALL_TIMEOUT_JSON:" + f"{json.dumps(result)}", + flush=True, + ) + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(2) + except (OSError, RuntimeError, ValueError) as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) + + result = {**base_result, "schemaName": schema_name} + print(f"INSTALLED_WORKDAY_DA_EXTENSION_JSON:{json.dumps(result)}") + + +if __name__ == "__main__": + main() diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 7ec8de32d..7d10ed0e8 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -19,16 +19,19 @@ pass it to step1 as PRE_SELECTED_INTEGRATION. Step1 will skip the Read `src/skills/connect/step1.md` and follow it. (Step 1 asks which integration, detects existing state, and dispatches — -ServiceNow to its own step files; Workday to either the lightweight -already-installed lifecycle or the full setup orchestrator, depending on -what's already in the environment.) +ServiceNow to its own step files; Workday to the lightweight already-installed +lifecycle when the extension exists, otherwise to either the CEA setup +orchestrator or the DA Workday connect skill, depending on which kind of ESS +agent is installed.) --- ## Routing Each integration routes differently — ServiceNow has its own step files; -Workday delegates to the setup orchestrator: +Workday uses the shared lifecycle for an existing extension, or delegates a +new installation to either the CEA setup orchestrator or the DA Workday +connect skill: - **ServiceNow**: `src/skills/connect/servicenow/` - Steps template: `src/skills/connect/servicenow/steps.md` @@ -44,7 +47,7 @@ Workday delegates to the setup orchestrator: - Step 3 (Basic): `step3-basic.md` — install extension pack (Basic fields) - Step 4: `step4.md` — verify connection -- **Workday**: two paths, chosen automatically by `step1.md`: +- **Workday**: three paths, chosen automatically by `step1.md`: - **Extension already installed elsewhere** — a lightweight lifecycle that confirms the extension and its connections, wires this agent's Workday topics, and validates the connection end to end: @@ -54,14 +57,20 @@ Workday delegates to the setup orchestrator: `.local/connect/workday/lifecycle.json`. This same runner + contract pattern is what a future integration reuses — a new ISV only needs its own contract file and action fragments, not a new runner. - - **Nothing installed yet** — the full **setup orchestrator** - (`src/skills/setup/SKILL.md`), which provisions the Power Platform - environment, installs the ESS base agent, provisions the Entra app, - configures the Workday tenant, installs the extension pack, and verifies - the connection. It is resume-aware: if setup was already started it picks - up at the first unverified step, and it fast-forwards steps that are - already done. State: `setupStatus` in `.local/connect/workday/config.json` - + `.local/setup/workday/tasks.md`. + - **Nothing installed yet, CEA agent** — the full **setup orchestrator** + (`src/skills/setup/SKILL.md`). It sequences the six CEA Workday setup + skills (environment, ESS install, Entra app, tenant config, extension + pack, topic) using the master checklist as a resume-aware spine. State: + `.local/setup/workday/tasks.md` + `setupStatus` in + `.local/connect/workday/config.json`. + - **Nothing installed yet, DA agent** — the **DA Workday connect skill** + (`src/skills/setup/workday-da/SKILL.md`). It sequences four steps + (extension pack, Entra app, tenant config, verify) the same resume-aware + way. State: `.local/setup/workday-da/tasks.md` + `setupStatus` in + `.local/connect/workday-da/config.json`. + + `src/skills/connect/step1.md` reads `.local/setup/config.json`'s + `selected_products` to choose the correct installation path. 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 c749d94ac..84f96661e 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -11,10 +11,13 @@ Build a list of connected integrations (if any): - **ServiceNow** — connected if `.local/connect/servicenow/steps.md` exists and all items are checked. -- **Workday** — connected if either: +- **Workday** — connected if any of these are true: - `.local/connect/workday/config.json` exists and its `setupStatus` shows - every setup row (`S1.1` … `S6.2`) in state `done` (the setup orchestrator - ran a full install for this agent), or + every CEA setup row (`S1.1` … `S6.2`) in state `done` (the CEA setup + orchestrator owns this state), + - `.local/connect/workday-da/config.json` exists and its `setupStatus` + shows every DA row (`DA1.1` … `DA4.1`) in state `done` (the DA Workday + connect skill owns this state), or - `.local/connect/workday/lifecycle.json` exists and every phase is `done` (this agent was wired to an extension already installed elsewhere — see 1.3 below). @@ -216,9 +219,26 @@ below): read `src/skills/connect/workday/SKILL.md` and follow it. It re-verifies everything live before reporting "connected" — it never trusts the saved state on its own. -**Otherwise**, check whether a Workday extension already exists **anywhere in -this environment**, independent of whether this particular agent has been -set up against it yet: +**Otherwise**, first find out which kind of ESS agent is in this environment. +Read `.local/setup/config.json` and look at +`selected_products` (each entry is one of `da.esshr` / `da.essit` / `da.esshub` +/ `cea.esshr` / `cea.essit` / `cea.esshub`): + +- **No `.local/setup/config.json`, or `selected_products` is empty** — no ESS + agent has been installed in this environment yet. + + **Message:** + + I don't see an Employee Self-Service agent installed in this environment yet. + Run `/setup` first, then come back and run `/connect workday` again. + + **End message.** + + Stop here. + +**Once an ESS agent is confirmed**, check whether a Workday extension already +exists **anywhere in this environment**, independent of whether this +particular agent has been set up against it yet: ``` python scripts/flightcheck/cli.py --checkpoint WD-PKG-001 @@ -230,16 +250,18 @@ install. Read `src/skills/connect/workday/SKILL.md` and follow it; it shows the user what it will check before doing anything, and only makes changes once they confirm. -**If anything other than `Passed`** (no extension installed yet, or its -package can't be confirmed): fall back to the full setup path. Workday -connection is then handled by the **setup orchestrator**, which provisions -the Power Platform environment, installs the ESS base agent, provisions the -Entra app, configures the Workday tenant, installs the extension pack, and -verifies the connection. It is resume-aware: if setup was already started it -picks up at the first unverified step, and it fast-forwards steps that are -already done. +**If anything other than `Passed`**, route by the installed agent architecture: + +- **Any `selected_products` entry starts with `da.`** — read + `src/skills/setup/workday-da/SKILL.md` and follow it. This path installs the + Workday extension package, provisions the Workday Entra app, configures the + Workday tenant, and verifies the connection. It is resume-aware. -Read `src/skills/setup/SKILL.md` and follow it. +- **Otherwise, only `cea.*` entries are present** — read + `src/skills/setup/SKILL.md` and follow it. This path provisions the Power + Platform environment, installs the ESS base agent, provisions the Entra app, + configures the Workday tenant, installs the extension pack, and verifies the + connection. It is resume-aware. ### If the user said something else diff --git a/solutions/ess-maker-skills/src/skills/connect/steps.md b/solutions/ess-maker-skills/src/skills/connect/steps.md index 1605edf75..fddb22492 100644 --- a/solutions/ess-maker-skills/src/skills/connect/steps.md +++ b/solutions/ess-maker-skills/src/skills/connect/steps.md @@ -4,6 +4,8 @@ This folder does not use a top-level steps.md. Each integration has its own steps.md and config.json under its subfolder: - `servicenow/steps.md` + `servicenow/config.json` -- `workday/steps.md` + `workday/config.json` (future) +- Workday has no `connect/workday/` subfolder — it's handled by + `src/skills/setup/SKILL.md` (CEA) or `src/skills/setup/workday-da/SKILL.md` + (DA), selected by `step1.md` based on which kind of ESS agent is installed. See SKILL.md for routing logic. 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 new file mode 100644 index 000000000..826735ca1 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/SKILL.md @@ -0,0 +1,181 @@ + +# Workday Connect (DA) — Orchestrator + +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. + +This router sequences the four Workday connect steps for the **Declarative Agent +(DA)** flavor of Employee Self-Service, using the master checklist as a +**resume-aware spine**: it renders the working checklist on first run, resumes at +the first unverified step, and dispatches to the owning step's playbook. It +**never** advances past a `MANUAL` / attestation row on a flightcheck pass alone — +those require explicit user acknowledgement (enforced by +[`shared/checklist-updater.md`](./shared/checklist-updater.md)). + +This skill assumes the DA Employee Self-Service base agent itself is already +installed — that's owned by `/setup`, not by this skill. DA-1 checks for it and +sends you to `/setup` first if it isn't there yet. + +--- + +## Handling Workday credentials — never put secrets in chat + +The Workday **password is a secret**. **Never** ask for it with a chat question +(`vscode_askQuestions`, or a plain "paste your Workday password" message) — the +chat question tool has no masked-input option, so anything typed is recorded +verbatim in the transcript. + +When a Workday secret is genuinely required (only the FlightCheck Workday SOAP +workflow tests need one), it is collected **exclusively** through a masked +input that keeps the value out of chat history: + +- the `.vscode/mcp.json` `workdayPass` input — a `promptString` with + `"password": true`, which VS Code masks and substitutes directly into the + check environment, or +- the FlightCheck CLI's own `getpass` prompt when you run + `python scripts/flightcheck/cli.py --scope workdayda` in the terminal. + +Non-secret connection identifiers (tenant, SOAP/REST/token URLs, OAuth client +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. + +--- + +## Start + +1. **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. + +2. **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"`. + +3. **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.** + + **Message:** + + Here's the checklist for connecting Workday to your agent: + + **1. Workday extension package** + - {m} Install the Workday extension package + + **2. Workday single sign-on (Entra)** + - {m} Create the Workday single sign-on app + - {m} Expose the Workday API permission + - {m} Grant admin consent + - {m} Assign users to the Workday app + - {m} Map the sign-in identifier + - {m} Set the SAML signing option + - {m} Confirm a single sign-in tenant + + **3. Workday tenant configuration** + - {m} Register the Workday API client + - {m} Capture your Workday connection details + - {m} Activate the Workday authentication policy + - {m} Match the signing certificate + + **4. Verify your Workday connection** + - {m} Review your Workday connection + + Picking up at: {title of the first item whose state is not `done`}. + + **End message.** + + Then walk the items in Step order (DA1.1, DA2.1 … DA4.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. + +4. If **every** item is `done`, show the **All done** message and stop. + +--- + +## Dispatch + +**Persist each row the moment its checkpoint passes.** Every step calls +[`shared/checklist-updater.md`](./shared/checklist-updater.md) per row, inline — +updating both the working checklist and the durable `setupStatus` mirror +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. + +### DA1.1 — Install the Workday extension package (DA-1) + +Read `src/skills/setup/workday-da/install-extension.md` and follow it. That +playbook checks the DA base agent is installed and sends the user to `/setup` +if it isn't, attempts an automated install of the Workday extension package, +falls back to a guided manual AppSource install if automation isn't available +in this tenant, verifies the package landed (`WD-DA-PKG-001`), and updates row +**DA1.1** through the shared checklist-updater. + +When it returns, go back to **Start** to resume at the next unverified row. + +### DA2.1 through DA2.7 — Provision the Workday Entra app (DA-2) + +Read `src/skills/setup/workday-da/provision-entra-app.md` and follow it. That +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 +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. + +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 scopes and activates the authentication 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. + +When it returns, go back to **Start** to resume at the next unverified row. + +### DA4.1 — Verify your Workday connection (DA-4) + +Read `src/skills/setup/workday-da/verify-connection.md` and follow it. That +playbook re-runs `WD-DA-PKG-001` to reconfirm the extension package, summarizes +the Entra and tenant configuration recorded in +`.local/connect/workday-da/config.json`, and clearly states what this skill +does — and does not yet — verify about the live connection (see the "Deferred +to a follow-up" note in `tasks.md`), pointing to a full FlightCheck run for +anything beyond that. It updates row **DA4.1** through the shared +checklist-updater (`advisory` gate — completes once shown, regardless of +findings). + +When it returns, go back to **Start** — every row should now be `done`. + +## All done + +**Message:** + +Your Workday connection checklist is complete. Type `/menu` to see what you can +do next. + +**End message.** 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 new file mode 100644 index 000000000..ec3734a8f --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-tenant.md @@ -0,0 +1,406 @@ + +# 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-TENANT-001` — Tenant Setup – Security + connection fields captured | attest | +| DA3.3 | `WD-TENANT-001` — authentication policy scoped to the OAuth client + activated | attest | +| DA3.4 | `WD-CONN-102` *(reuse)* — Workday X.509 signing cert matches the Entra one | manual/attest | + +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. + +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)** → **authentication policy +(DA3.3)**. The API client is registered **before** the authentication policy +because the policy must be scoped to the OAuth client identity, which only +exists once the client is registered. 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`. + +--- + +## DA3.0 — Workday administrator gate + +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. + +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. + +**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. + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Workday administrator", + "question": "Have you looped in a Workday admin to perform the Workday side of configuration?", + "options": [ + { "label": "Yes, I have", "recommended": true }, + { "label": "No, I have not" } + ], + "allowFreeformInput": false + } +] +``` + +**If the user chose "Yes, I have":** +- Set `GATE_RESULT = "pass"` and + `GATE_EVIDENCE = { "verifiedBy": "attested", "note": "user confirmed a Workday administrator is available to perform DA3.1–DA3.4 with them" }`. +- 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. + +--- + +## DA3.0c — Upload the X.509 signing certificate & confirm certificate parity *(completes DA3.4)* + +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. + +**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. + +**End message.** + +Wait for the user, then verify the certificate parity against the certificate +DA-2 activated in Entra. + +**Message:** + +Now I'll compare the certificate you uploaded in Workday against the one activated +in Entra to make sure they match. + +**End message.** + +**Verify (WD-CONN-102):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 +``` + +`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). + +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. + +**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. + +**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`. +- **"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:** + +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. + +**End message.** + +Wait for the user, then continue to DA3.1. + +--- + +## 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. **Register the client before +touching the authentication policy (DA3.3)** — the policy is scoped to this +client's identity. + +**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. + +**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`: + +- `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. + +`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). + +**Message:** + +Now I'll confirm the Workday API client you registered was captured correctly. + +**End message.** + +**Verify (WD-API-CLIENT-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-API-CLIENT-001 +``` + +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. +- 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 the captured 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. + +--- + +## DA3.3 — Scope & activate the authentication policy + +Scope the authentication policy to the OAuth client from DA3.1/DA3.2 and activate +it. + +**Message:** + +In Workday, run **Manage Authentication Policies**. Add or edit the policy so it +is scoped to the **OAuth client you registered in the previous step**, and allow +**SAML** as an allowed authentication type. Then run **Activate All Pending +Authentication Policy Changes** to make it live. Type **done** when the changes +are activated. + +**End message.** + +Wait for the user, 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 +``` + +This echoes the captured `tenant` / `restBaseUrl` / `soapBaseUrl` / `appIdUri` and +restates the Tenant Setup – Security and 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 the policy is scoped and activated. + +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. + +--- + +## Done + +When DA3.1–DA3.4 are all `done`, return control to the orchestrator (`SKILL.md`) +to resume at the next unverified row. + +**Message:** + +Your Workday tenant is configured — the signing certificate, Tenant Security, the +API client, and the authentication policy are all set. Next I'll review your +Workday connection and let you know what's left. + +**End message.** 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 new file mode 100644 index 000000000..aa46a8696 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/install-extension.md @@ -0,0 +1,130 @@ + +# DA-1 — Install the Workday Extension Package + +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. + +This step completes **DA1.1** on the Workday connect checklist. It confirms your +DA Employee Self-Service base agent is installed, then installs the Workday +extension package against it — attempting an automated install first and +falling back to guided manual steps only if this tenant's Marketplace catalog +doesn't support it. + +--- + +## P1.0 — Check for the DA base agent, and install the extension if it's missing + +Run the checkpoint that reports both facts at once — whether a DA base agent +exists, and whether Workday is already installed against it: + +``` +python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 +``` + +- **`PASSED`** → the Workday extension package is already installed. Show the + result, record `vertical` (`hr`/`it`, from the check's finding) into + `.local/connect/workday-da/config.json`, and go to **record DA1.1** below. +- **`FAILED`** with "No DA Employee Self-Service base agent … was found" → + your DA base agent isn't installed yet. Stop here — this skill doesn't + install the base agent. + + **Message:** + + I don't see a DA Employee Self-Service agent installed in this environment + yet. Run `/setup` first to install it, then come back and run + `/connect workday` again. + + **End message.** + + Halt this skill entirely — do not proceed to DA-2 or DA-3. + +- **`FAILED`** with "The Workday extension package is not installed for the + {edition} edition" → the base agent is present but Workday isn't installed + yet. Continue to **P1.1**. +- **`WARNING`** (Dataverse call failed, e.g. permissions or a transient error) + → show the result verbatim and stop; ask the user to resolve the underlying + issue (commonly a missing Dataverse role) and re-run this step. + +--- + +## P1.1 — Attempt an automated install + +``` +python scripts/install_workday_da_extension.py --url "{ENVIRONMENT_URL}" --vertical "{hr|it}" +``` + +Use the `vertical` the check just reported (whichever DA base agent edition — +HR or IT — is installed). `ENVIRONMENT_URL` is the Dataverse endpoint from +`.local/config.json`. + +Parse the script's JSON marker line: + +- **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → the extension installed (or was + already installed and the script confirmed it). Re-run + `--checkpoint WD-DA-PKG-001` to confirm it now reports `PASSED`, then go to + **record DA1.1**. +- **`WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:`** → this tenant's Marketplace + catalog doesn't list the Workday extension package for the DA agent, so it + can't be installed automatically here. This is expected in some tenants — + it is not a failure. Continue to **P1.2** (manual install). +- **`WORKDAY_DA_EXTENSION_INSTALL_TIMEOUT_JSON:`** → the install was started + but didn't finish within the wait window. + + **Message:** + + The Workday extension package install is still in progress in Power + Platform — this can take a few minutes. Check back shortly and I'll + re-verify it. + + **End message.** + + Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001`. + Loop until it passes, or fall back to **P1.2** if the user reports the + install failed in the portal. +- Any other exit / error → show the error verbatim and continue to **P1.2** + (manual install) so the user isn't blocked by an automation failure. + +--- + +## P1.2 — Guided manual install (fallback) + +**Message:** + +I couldn't install the Workday extension package automatically in this +environment, so let's do it from the admin center — the same place you +installed the base Employee Self-Service agent: + +1. Open the [Microsoft 365 admin center](https://admin.microsoft.com) (or + AppSource, if that's where you installed the base agent). +2. Find the **Workday extension for Employee Self-Service** package for the + **{edition}** edition of your agent. +3. Deploy it to this environment. +4. Wait for the deployment to finish, then tell me it's done and I'll verify + it. + +**End message.** + +Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001`. Loop +until it passes. While it is not yet passing, keep DA1.1 `in-progress`. + +--- + +## Record DA1.1 + +When `WD-DA-PKG-001` is `PASSED`: + +1. Merge `vertical` (`hr`/`it`) into `.local/connect/workday-da/config.json` + (round-trip merge — never drop other keys). +2. Call [`shared/checklist-updater.md`](./shared/checklist-updater.md) with + `STEP_ID = "DA1.1"`, `CHECKPOINT_RESULT = "PASSED"`, `GATE = "prog"`. + +**Message:** + +The Workday extension package is installed for the **{edition}** edition of +your agent. + +**End message.** + +Return to the DA orchestrator (`src/skills/setup/workday-da/SKILL.md`, +**Start**) to resume at the next unverified row. 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 new file mode 100644 index 000000000..2bc7c3cb4 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/provision-entra-app.md @@ -0,0 +1,644 @@ + +# DA-2 — Provision the Workday Entra App + +Role: **App / Cloud Application Administrator** (a **consent-capable** role — +Application Administrator, Cloud Application Administrator, Privileged Role +Administrator, or Global Administrator — is required for the admin-consent step). +This step configures the Microsoft Entra app registration for the Workday SSO +integration so the agent can call Workday on behalf of the signed-in user. It owns +master-checklist rows **DA2.1 through DA2.7**. + +Depends on DA-1 (the Workday extension package must already be installed). It is +**Entra-only** — it needs Microsoft Graph, not Dataverse. Everything below is +identical to how a CEA Employee Self-Service agent provisions its Workday Entra +app — Entra app registration doesn't differ by agent architecture — 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 +(e.g. do not print `WD_ENTRA_APP_OBJECT_ID = ...`). + +**Graph-first with a portal fallback on every step.** Each configuration step is +attempted through Microsoft Graph (`az rest` / `az ad`); if a Graph call fails for +a permission or tenant-policy reason, fall back to the portal instructions shown +in that step rather than aborting. + +**Checkpoints this step drives (run each in isolation):** + +| Step | Checkpoint | Gate | +|------|-----------|------| +| DA2.1 | `WD-CONN-102` *(reuse)* — SAML signing-certificate health | prog instantiate; healthy-state MANUAL | +| DA2.2 | `WD-ENTRA-SCOPE-001` — scope exposed + connector pre-authorized + Graph perms | prog | +| DA2.3 | `WD-ENTRA-CONSENT-001` — admin consent granted | prog; escalate to manual | +| DA2.4 | `WD-ASSIGN-001` — enterprise-app user assignment (or not required) | prog | +| DA2.5 | `WD-ENTRA-NAMEID-001` — NameID `claimsMappingPolicy` | prog; degrade to manual | +| DA2.6 | `WD-ENTRA-SIGNOPT-001` — SAML signing option (portal-only) | manual | +| DA2.7 | `WD-CONN-010` *(reuse)* — single-tenant federation alignment | attest | + +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. + +**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 +`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`. + +--- + +## DA2.0 — Role gate (App / Cloud Application Administrator) + +Apply the shared [`shared/permission-gate.md`](shared/permission-gate.md) before +any Entra work, with: + +- `REQUIRED_ROLE` = `"Application Administrator"` (or Cloud Application + Administrator / Privileged Role Administrator / Global Administrator / app owner) +- `GATE_MODE` = `"programmatic"` +- `STEP_ID` = `"DA2.1"` +- `ROLE_QUERY` = a Microsoft Graph directory-role membership check for the + signed-in user: + + ``` + az rest --method GET --url "https://graph.microsoft.com/v1.0/me/memberOf?%24select=displayName" --query "value[].displayName" -o json + ``` + + The role is held if the returned role names include **`Application + Administrator`**, **`Cloud Application Administrator`**, **`Privileged Role + Administrator`**, or **`Global Administrator`**. Treat an + `Insufficient privileges` / `Authorization_RequestDenied` / forbidden response + as "role not held". If the query errors for an unrelated reason (network, not + signed in), follow the gate's retry-then-attest fallback — never assume pass. + +If `GATE_RESULT` is `"stop"`, **halt** — do not continue. Otherwise carry +`GATE_EVIDENCE` forward (recorded when the DA2 rows are updated). + +--- + +## DA2.0b — Capture the Workday tenant URL *(enables deterministic app discovery)* + +Knowing the Workday tenant lets DA2.1 pin the **exact** Entra SSO app for this +Workday tenant — the app federated to it carries `http://www.workday.com/{tenant}` +as its SAML identifier — instead of guessing among look-alike "Workday" apps. This +step is **idempotent** and **best-effort**: if the URL isn't handy, skip it and +DA2.1 falls back to an interactive picker. + +**If `tenant` is already set** in `.local/connect/workday-da/config.json`, skip +this step — it was captured here on an earlier run, or by DA-3. + +Otherwise ask for the Workday URL with the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Workday URL", + "question": "Paste the address-bar URL from your browser while you're signed in to Workday (for example https://impl.workday.com/yourcompany/d/home.htmld). Don't have it handy? Leave it blank and I'll identify the Workday app another way.", + "allowFreeformInput": true + } +] +``` + +**If the user provides a URL**, parse it silently (do not echo the parsing): + +- `WD_TENANT` — the first path segment after the host + (`https://impl.workday.com/contoso_impl/d/…` → `contoso_impl`). +- `WD_BASE_URL` — the scheme + host (`https://impl.workday.com`). +- `WD_TOKEN_HOST` — the Workday **services** host derived from the web host: + - `impl.workday.com` → `wd2-impl-services1.workday.com` + - `wd5.myworkday.com` → `wd5-services1.myworkday.com` + - `{dcN}.myworkday.com` → `{dcN}-services1.myworkday.com` + + If the host matches no known pattern, keep `WD_TENANT` / `WD_BASE_URL` and leave + `WD_TOKEN_HOST` for DA-3 to resolve from the API-client token endpoint. + +**Persist** to `.local/connect/workday-da/config.json` (merge — keep other keys, +per [`shared/config-schema.md`](shared/config-schema.md)): `tenant` = +`WD_TENANT`, `baseUrl` = `WD_BASE_URL`, and `tokenHost` = `WD_TOKEN_HOST` when +derived. + +**If the user leaves it blank**, record nothing and continue — DA2.1 will +identify the app by display name and ask you to choose if more than one matches. + +--- + +## 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 +gallery app, in SAML mode, with a token-signing certificate. It is **idempotent** — +re-running never creates a duplicate. + +**First, check whether the app already exists.** Read +`.local/connect/workday-da/config.json`. If `entraAppObjectId` is set, the app was +already created (by this step or by an earlier `/connect workday` run) — load +`WD_ENTRA_APP_OBJECT_ID` (from `entraAppObjectId`), `WD_ENTRA_APP_ID` (from +`entraAppId`), and re-resolve the service-principal id: + +``` +az ad sp list --filter "appId eq '{WD_ENTRA_APP_ID}'" --query "[0].id" -o tsv +``` + +Save it as `WD_ENTRA_SP_ID` and skip to **verify (WD-CONN-102)** below. + +**If no app is recorded yet**, discover or instantiate it. + +**First, when the Workday tenant is known** — DA2.0b recorded `tenant` in +`.local/connect/workday-da/config.json` — pin the app **deterministically** by its +tenant-scoped SAML identifier. The Entra app federated to this Workday tenant +carries `http://www.workday.com/{tenant}` in its `identifierUris`, so no guessing +is needed: + +``` +az ad app list --all --query "[?identifierUris[?contains(@, 'workday.com/{tenant}')]].{name:displayName, appId:appId, id:id, identifierUris:identifierUris}" -o json +``` + +- **Exactly one match** → this is unambiguously the right app. Save its `appId` → + `WD_ENTRA_APP_ID` and its `id` → `WD_ENTRA_APP_OBJECT_ID`, then resolve the + service-principal id (`az ad sp list --filter "appId eq '{WD_ENTRA_APP_ID}'" + --query "[0].id" -o tsv`) → `WD_ENTRA_SP_ID`. **Do not prompt** — skip to + **Persist** below. +- **More than one match** (rare — two apps carry this tenant's identifier) → use + the interactive picker described below, but list **only these matches**. +- **No match** → no existing app federates to this Workday tenant; fall through to + the by-name search below (which normally leads to creating a fresh app). + +**Otherwise — the tenant is unknown (DA2.0b was skipped) or the tenant pin found +no match** — look for an existing Workday SAML app by name: + +``` +az ad sp list --display-name "Workday" --query "[].{name:displayName, appId:appId, id:id, sso:preferredSingleSignOnMode, replyUrls:replyUrls}" -o json +``` + +- **If Workday SAML app(s) already exist** — consider only the returned apps in + SAML mode (`sso == "saml"`): + + - **Exactly one** → save its `appId` → `WD_ENTRA_APP_ID` and its `id` (the + service-principal id) → `WD_ENTRA_SP_ID`, then resolve its **application** + object id — the `az ad sp list` query above returns the *service-principal* + id, **not** the app object id, so query it explicitly: + + ``` + az ad app list --filter "appId eq '{WD_ENTRA_APP_ID}'" --query "[0].id" -o tsv + ``` + + → `WD_ENTRA_APP_OBJECT_ID`. Then **skip to Persist below** so `entraAppId` is + written to config. + + - **More than one** → do **not** guess which is correct. The app chosen here is + pinned to `entraAppId` in config, and every later step and FlightCheck check + (consent, user assignment, NameID) keys off it — picking the wrong sibling + makes a correctly-configured app report FAILED. Ask the user to choose. Use + the `vscode_askQuestions` tool, building the `options` array **dynamically + from the returned SAML apps** — one option per app, plus a final "Create a new + app instead" option: + + ```json + [ + { + "header": "Workday Entra app", + "question": "I found more than one Workday enterprise app in your tenant. Which one should ESS use for Workday single sign-on?", + "options": [ + { "label": "Workday (ESS Copilot)", "description": "SAML · reply URL https://…/ess · provisioned by this kit", "recommended": true }, + { "label": "Create a new app instead", "description": "Provision a fresh \"Workday (ESS Copilot)\" app from the gallery" } + ], + "allowFreeformInput": false + } + ] + ``` + + Emit one option object per returned SAML app: set `label` to the app's + `displayName` and `description` to its SSO mode plus its first reply URL (a + human-meaningful disambiguator — do **not** put the full app/object GUID in + the description; append only the last 6 characters of the `appId` if two apps + are otherwise indistinguishable). Mark the app named exactly **`Workday (ESS + Copilot)`** as `recommended` when present — that is the app this kit + provisions. Then: + - **User picks an existing app** → map the chosen label back to that app and + save its `appId` → `WD_ENTRA_APP_ID` and its `id` (the service-principal + id) → `WD_ENTRA_SP_ID`, then resolve its **application** object id — the + `az ad sp list` results carry the *service-principal* id, **not** the app + object id, so query it explicitly: + + ``` + az ad app list --filter "appId eq '{WD_ENTRA_APP_ID}'" --query "[0].id" -o tsv + ``` + + → `WD_ENTRA_APP_OBJECT_ID`. Then **skip to Persist below** so `entraAppId` + is written to config — do **not** jump ahead to verify. + - **User picks "Create a new app instead"** → follow the **If none exists** + instantiate path below. + +- **If none exists**, instantiate from the Workday gallery template. Find the + template id, then instantiate it: + + ``` + az rest --method GET --url "https://graph.microsoft.com/v1.0/applicationTemplates?%24filter=displayName%20eq%20'Workday'" --query "value[0].id" -o tsv + ``` + + ```powershell + $body = @{displayName="Workday (ESS Copilot)"} | ConvertTo-Json + $body | Out-File "$env:TEMP\ess-wd-template.json" -Encoding utf8 + az rest --method POST --url "https://graph.microsoft.com/v1.0/applicationTemplates/{TEMPLATE_ID}/instantiate" --headers "Content-Type=application/json" --body "@$env:TEMP\ess-wd-template.json" + ``` + + From the response, save `application.appId` → `WD_ENTRA_APP_ID`, + `application.id` → `WD_ENTRA_APP_OBJECT_ID`, `servicePrincipal.id` → + `WD_ENTRA_SP_ID`. Then set SAML mode, the identifier/reply URLs, and add **and + activate** a token-signing certificate (set + `preferredSingleSignOnMode = "saml"`, `identifierUris`/`web.redirectUris`, then + `addTokenSigningCertificate` and set `preferredTokenSigningKeyThumbprint` to + activate it — capture the thumbprint + expiry). + + **Portal fallback (permission error on instantiate/PATCH):** + + **Message:** + + I need permission to create and configure enterprise applications in your Entra + tenant, which requires the **Application Administrator** or **Cloud Application + Administrator** role. If you can't get that role, ask your IT admin to create a + Workday enterprise app from the Entra gallery (SAML mode, with a token-signing + certificate) and share its Application ID with you, then tell me and I'll pick + it up from there. + + **End message.** + + Wait for the user, then re-resolve the app with the `az ad sp list` filter above. + +**Persist** the app identity to `.local/connect/workday-da/config.json` (merge — +keep other keys, per [`shared/config-schema.md`](shared/config-schema.md)): + +- `entraAppId` = `WD_ENTRA_APP_ID` +- `entraAppObjectId` = `WD_ENTRA_APP_OBJECT_ID` + +**Verify (WD-CONN-102):** + +This is the **first FlightCheck checkpoint in this skill that uses Microsoft +Graph**. FlightCheck signs in to Graph with its **own** token — separate from the +`az` sign-in used to create the app above and from the earlier environment +sign-in — so the command below **opens a browser window for a Microsoft Graph +sign-in** the first time it runs. Show the message first, then run the command. +Do **not** wait for a chat reply before running it, and do **not** cancel or +re-run the command while it appears to pause: it is **blocked on the browser +sign-in, not hung**, and returns on its own once the sign-in completes. (Later +Graph checkpoints reuse this token and run silently.) + +**Message (do NOT wait for a response — continue immediately):** + +I'm running the first readiness check now — it confirms the single sign-on signing +certificate for your Workday app is present and healthy. A browser window will open +for a Microsoft Graph sign-in — please complete it with the same admin account, and +I'll continue automatically once it finishes. + +**End message.** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-CONN-102 +``` + +`WD-CONN-102` reports the Entra-side signing-certificate health. It returns +`MANUAL` for the healthy state because Workday-side certificate parity is verified +later in DA-3 (row DA3.4). Present the certificate/thumbprint result to the +user, then update **DA2.1** via +[`shared/checklist-updater.md`](shared/checklist-updater.md) with +`STEP_ID="DA2.1"`, `GATE="manual"`, `CHECKPOINT_RESULT` = the checkpoint result, +and `ACK` = the user's explicit confirmation that the certificate was added and +activated. Persist the DA2.0 `GATE_EVIDENCE`. + +--- + +## DA2.2 — Expose the API scope, pre-authorize the connector, grant Graph perms + +Configure the app (`WD_ENTRA_APP_OBJECT_ID` from DA2.1) so the Power Platform +Workday connector can obtain an on-behalf-of token. + +1. **Expose the `user_impersonation` scope** — apply + [`connect/azure/app-registration.md`](../../connect/azure/app-registration.md) + **§B.4** against this app, with `APP_OBJECT_ID` = `WD_ENTRA_APP_OBJECT_ID`, + `APP_CLIENT_ID` = `WD_ENTRA_APP_ID`, and `SCOPE_RESOURCE_LABEL` = `Workday`. + That sets the identifier URI `api://{WD_ENTRA_APP_ID}`, generates a + `SCOPE_GUID`, and exposes `user_impersonation` (with a built-in portal + fallback). + +2. **Pre-authorize the Workday connector** — apply the same file's **§B.5** with + `CONNECTOR_APP_ID` = `4e4707ca-5f53-46a6-a819-f7765446e6ff` (the Power Platform + **Workday** connector — never the ServiceNow `c26b24aa`), `APP_OBJECT_ID` = + `WD_ENTRA_APP_OBJECT_ID`, and the `SCOPE_GUID` from step 1. + +3. **Add the Graph delegated permissions** `openid`, `profile`, `User.Read`: + + ```powershell + $body = @{requiredResourceAccess=@(@{ + resourceAppId="00000003-0000-0000-c000-000000000000" + resourceAccess=@( + @{ id="37f7f235-527c-4136-accd-4a02d197296e"; type="Scope" } + @{ id="14dad69e-099b-42c9-810b-d002981feec1"; type="Scope" } + @{ id="e1fe6dd8-ba31-4d61-89e7-88639da4683d"; type="Scope" } + ) + })} | ConvertTo-Json -Depth 6 + $body | Out-File "$env:TEMP\ess-wd-graphperms.json" -Encoding utf8 + az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/{WD_ENTRA_APP_OBJECT_ID}" --headers "Content-Type=application/json" --body "@$env:TEMP\ess-wd-graphperms.json" + ``` + + **Portal fallback (PATCH fails):** + + **Message:** + + I couldn't add the Microsoft Graph permissions automatically. You can add them + in the portal: open https://entra.microsoft.com → **App registrations** → your + Workday app → **API permissions** → **Add a permission** → **Microsoft Graph** + → **Delegated permissions** → add **openid**, **profile**, and **User.Read**. + Type **done** when you're finished. + + **End message.** + + Wait for the user, then continue. + +**Persist** to `.local/connect/workday-da/config.json` (merge): `scopeGuid` = +`SCOPE_GUID`, `appIdUri` = `api://{WD_ENTRA_APP_ID}`, `entraSSO` = `true`. + +**Message:** + +Now I'll verify the Workday app exposes its API permission and that the Power +Platform Workday connector is pre-authorized to call it. + +**End message.** + +**Verify (WD-ENTRA-SCOPE-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SCOPE-001 +``` + +- **`PASSED`** → update **DA2.2** via + [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.2"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`; persist + `GATE_EVIDENCE`. Continue to DA2.3. +- **`FAILED`** → the result names which of the three (scope / pre-authorization / + Graph perms) is missing. Redo that step (Graph or portal fallback), then re-run + the checkpoint. Keep DA2.2 `in-progress` until it passes. +- **`WARNING` / `SKIPPED`** → surface the message; re-run once. A `SKIPPED` means + Graph auth or the app couldn't be resolved — confirm DA2.1 completed first. + +--- + +## DA2.3 — Grant admin consent for the Graph delegated permissions + +Grant tenant-wide admin consent so the on-behalf-of handshake works for all end +users. Attempt it through Graph; if the caller lacks a consent-capable role, +**escalate to manual consent** rather than hard-failing. + +Grant admin consent for the app's service principal (portal is the reliable path; +attempt the portal/`az` grant): + +**Message:** + +Now I need an administrator to grant consent for the Workday app's permissions. +Open https://entra.microsoft.com → **Enterprise applications** → the **Workday +(ESS Copilot)** app → **Permissions** → **Grant admin consent for +<your tenant>**, then approve the prompt. This needs a consent-capable role +(Application Administrator, Cloud Application Administrator, Privileged Role +Administrator, or Global Administrator). Type **done** when the consent is granted. + +**End message.** + +Wait for the user, then verify. + +**Message:** + +Now I'll confirm that admin consent was recorded for the Workday app's +permissions. + +**End message.** + +**Verify (WD-ENTRA-CONSENT-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-CONSENT-001 +``` + +- **`PASSED`** → update **DA2.3** via + [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.3"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to + DA2.4. +- **`FAILED`** → consent isn't recorded yet. + + **Message:** + + I don't see admin consent for the Workday app's Graph permissions yet. If you + don't hold a consent-capable role (Application Administrator, Cloud Application + Administrator, Privileged Role Administrator, or Global Administrator), ask an + administrator to run **Grant admin consent** on the Workday enterprise app, then + tell me and I'll re-check. + + **End message.** + + After the user confirms, re-run the checkpoint. Keep DA2.3 `in-progress` + (escalated to manual consent) until it passes. + +--- + +## DA2.4 — Enterprise-app user assignment (or confirm not required) + +Ensure the Workday enterprise app either does not require user assignment, or has +the ESS user security group assigned — otherwise the OBO handshake fails for end +users at first access. + +**Message:** + +Now I'll check whether the Workday enterprise app requires user assignment and, if +so, that the right users are assigned. + +**End message.** + +**Verify (WD-ASSIGN-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ASSIGN-001 +``` + +- **`PASSED`** (assignment satisfied via a group, or not required) → update + **DA2.4** via [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.4"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to + DA2.5. +- **`FAILED`** (assignment required, nothing assigned): + + **Message:** + + The Workday enterprise app requires user assignment but nothing is assigned yet. + Open https://entra.microsoft.com → **Enterprise applications** → the Workday app + → **Users and groups** → **Add user/group**, and assign the ESS user security + group (preferred over individual users). Type **done** when you've assigned it. + + **End message.** + + After the user confirms, re-run the checkpoint. Keep DA2.4 `in-progress` until + it passes. +- **`WARNING`** (assignment not required, or only individual users assigned) → this + is a hardening recommendation, not a blocker. Surface the message; treat a + passing-with-warning as a `prog` pass for the row only if the underlying state is + acceptable to the user, otherwise leave `in-progress` and let them assign a group. + +--- + +## DA2.5 — NameID claim mapping (`claimsMappingPolicy`) + +Map the SAML NameID claim so the value Workday receives equals the Workday User +Name. Attempt the `claimsMappingPolicy` create + assign through Graph; if the +policy route proves brittle, degrade to the manual portal path. + +Create a claimsMappingPolicy that overrides the NameID claim (map to the attribute +that equals the Workday User Name — typically `user.mail` or +`user.userPrincipalName`) and assign it to the Workday service principal +(`WD_ENTRA_SP_ID`) via +`POST /servicePrincipals/{WD_ENTRA_SP_ID}/claimsMappingPolicies/$ref`. + +**Portal fallback (policy create/assign fails, or the tenant blocks custom +policies):** + +**Message:** + +I couldn't set the NameID mapping automatically. You can set it in the portal: +open https://entra.microsoft.com → **Enterprise applications** → the Workday app → +**Single sign-on** → **Attributes & Claims** → edit the **Unique User +Identifier (Name ID)** claim so its source attribute equals the Workday User Name +(commonly **user.mail** or **user.userPrincipalName**). Type **done** when it's +set. + +**End message.** + +Wait for the user, then verify. + +**Message:** + +Now I'll verify the single sign-on user identifier (NameID) is mapped to the value +your Workday tenant expects. + +**End message.** + +**Verify (WD-ENTRA-NAMEID-001):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-NAMEID-001 +``` + +- **`PASSED`** (a NameID-overriding policy is assigned) → update **DA2.5** via + [`shared/checklist-updater.md`](shared/checklist-updater.md) with + `STEP_ID="DA2.5"`, `GATE="prog"`, `CHECKPOINT_RESULT="PASSED"`. Continue to + DA2.6. +- **`FAILED`** (no override — Entra sends the default UPN) → if your tenant + deliberately relies on the default `userPrincipalName` NameID **and** it already + equals the Workday User Name, this can be attested manually; otherwise create the + mapping (Graph or portal fallback) and re-run. Keep DA2.5 `in-progress` until + resolved. +- **`MANUAL`** (the policy route is unreadable — missing `Policy.Read.All`) → + degrade to a manual portal check: confirm the NameID mapping in the portal + (steps above), then treat DA2.5 as a `manual` row needing explicit + acknowledgement via + [`shared/checklist-updater.md`](shared/checklist-updater.md). + +--- + +## DA2.6 — "Sign SAML response and assertion" signing option *(portal-only)* + +This signing option has no documented Graph property, so it is a **manual portal +gate** — a Workday service provider that validates signatures rejects the +assertion if it is set wrong. + +**Message:** + +Next I'll cover the SAML signing option — this one has to be confirmed in the +portal, because the kit can't read the setting directly. + +**End message.** + +**Verify (WD-ENTRA-SIGNOPT-001):** this checkpoint always returns `MANUAL` (the kit +cannot read the setting). + +``` +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SIGNOPT-001 +``` + +Present the checkpoint's instructions — its remediation now names the customer's +own Entra SAML IdP identifiers (Issuer / Entity ID, SSO / Login URL, SP audience, +and federation-metadata URL, derived from the captured `tenantId` and +`entraAppId`) so they can match them against their Workday SP configuration. If the +earlier certificate check (DA2.1 / `WD-CONN-102`) surfaced a signing-certificate +thumbprint, restate it here too so the customer knows exactly which certificate +Workday must trust. Then: + +**Message:** + +One SAML setting can only be set in the portal. Open https://entra.microsoft.com → +**Enterprise applications** → the Workday app → **Single sign-on** → **SAML +Signing Certificate** → **Edit** → set **Signing Option** to **Sign SAML response +and assertion**, and **Save**. Then confirm your Workday tenant's SAML IdP is +configured with the **Issuer**, **SSO / Login URL**, and **SP audience** shown in +the check result above, and that it trusts the signing certificate you activated +earlier. Type **done** when it's set. + +**End message.** + +Then, per [`shared/checklist-updater.md`](shared/checklist-updater.md)'s manual +rule, ask for an explicit acknowledgement and update **DA2.6** with +`STEP_ID="DA2.6"`, `GATE="manual"`, `CHECKPOINT_RESULT="MANUAL"`, and `ACK` = the +user's explicit confirmation. On `ACK=true` the row becomes `done`; a `MANUAL` +result alone never completes it. + +--- + +## DA2.7 — Confirm single-Entra-tenant federation alignment + +Confirm exactly one Entra tenant federates to the Workday tenant ESS uses (a +misaligned or duplicate federation breaks user-context SAML SSO). + +**Message:** + +Now I'll review the Workday SAML federation to confirm exactly one Entra tenant is +linked to the Workday tenant your agent uses. + +**End message.** + +**Verify (WD-CONN-010):** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-CONN-010 +``` + +`WD-CONN-010` summarizes the federated Workday SAML app(s) and their entity IDs. +Present the result, then — this is an **attest** row — ask the user to confirm the +alignment and update **DA2.7** via +[`shared/checklist-updater.md`](shared/checklist-updater.md) with +`STEP_ID="DA2.7"`, `GATE="attest"`, `CHECKPOINT_RESULT` = the checkpoint result, +and `ACK` = the user's explicit confirmation. Persist the DA2.0 `GATE_EVIDENCE`. + +--- + +## Done + +**Message:** + +Your Workday Entra app is configured and verified — the API scope, connector +authorization, admin consent, user assignment, NameID mapping, and SAML signing +are all in place. Next we'll configure the Workday tenant side (DA-3). + +**End message.** + +Rows DA2.1–DA2.7 are now recorded in the checklist. Return control to the +orchestrator (`SKILL.md`) to resume at the next unverified row. Stop here — the +Workday tenant configuration is a separate step. 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 new file mode 100644 index 000000000..8cefb4e53 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/checklist-updater.md @@ -0,0 +1,259 @@ +# Master-Checklist Updater (DA) + +The single routine every DA Workday setup skill calls to update **its own rows** +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. + +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`). +The logic is identical — only the persisted files differ — so the two skills can +evolve independently. + +Every **Message** block is the exact text to show the user. Copy it verbatim. Do +not narrate tool calls. + +**Inputs from the calling file:** +- `STEP_ID` — the DA master checklist Step ID to update (e.g. `"DA3.1"`, + `"DA2.4"`). See the canonical rows in the checklist template + `src/skills/setup/workday-da/tasks.md`. A skill updates **only** the Step IDs + it owns. +- `NEW_STATE` — `"in-progress"` \| `"done"` \| `"blocked"`. +- `CHECKPOINT_RESULT` — the flightcheck result for the row's checkpoint, one of + `PASSED` \| `FAILED` \| `WARNING` \| `MANUAL` \| `null` (null = not run yet). +- `GATE` — the row's gate type: `"prog"` \| `"manual"` \| `"attest"` \| + `"advisory"` (from the DA master checklist row; also recorded in config per + `config-schema.md`). +- `ACK` — *(manual/attest rows only)* `true` once the user has explicitly + acknowledged the step and any evidence has been captured; otherwise `false`. + +**Outputs:** +- The matching checklist item in `.local/setup/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`). + +--- + +## Files + +- **Working copy (read/write):** `.local/setup/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. +- **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. + +Row shape in `tasks.md` (each item in the checklist template +`src/skills/setup/workday-da/tasks.md`): a checkbox line the user sees, followed +by an HTML comment the tooling reads. + +``` +- [ ] **** — + +``` + +- `- [ ]` / `- [x]` is the at-a-glance done marker. +- The hidden `id:` field is the `STEP_ID`; the hidden `status:` field carries the + full four-state value a single checkbox can't express. +- **Never surface a Step ID, checkpoint ID, or the hidden comment to the user** — + they see the checkbox and its description only. + +--- + +## U.0 — Show the checkpoint result to the user (in chat) + +**Timing — render this the instant a checkpoint run returns, and for a +manual/attest row *before* you ask the attestation question.** When a +`python scripts/flightcheck/cli.py --checkpoint ` run just produced +`CHECKPOINT_RESULT`, surface that result to the user — the U.0 table **and** the +U.0a manual steps — before touching any state and before any attestation, so every +checkpoint run has a visible outcome. Single-checkpoint runs never open the HTML +report, so this in-chat render is the only place the user sees the outcome; never +ask a user to attest to manual steps they have not been shown. If you already +rendered this checkpoint's result this pass (per a skill's post-checkpoint display +convention), do not repeat it — proceed to U.1. The U.1–U.3 status update below +runs afterwards (once any attestation is answered) and does **not** re-display the +result. + +- Skip this step when `CHECKPOINT_RESULT` is `null` (the checkpoint was not run + this pass — e.g. the row is only being moved to `in-progress`), or when + `workspace/flightcheck/results.json` does not exist. + +Read `workspace/flightcheck/results.json` — the run that led here wrote it. It has: + +```json +{ "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**: + +``` +| Check | Status | +| --- | --- | +| | | +``` + +Rules: +- **Never** include `checkpoint_id`, the Step ID, or any other internal + 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. +- 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 + the tools used to produce it. + +--- + +## U.0a — Show the manual verification steps to the user (in chat) + +Do this right after the U.0 table, before touching any state. A +`python scripts/flightcheck/cli.py --checkpoint ` run **never opens the HTML +report** — for `MANUAL` checks the verification steps must appear **in chat**, not +in a browser popup. This routine is what puts them there. + +- Skip this step when `CHECKPOINT_RESULT` is `null`, or when + `workspace/flightcheck/results.json` does not exist. + +Each entry in `results.json` carries the full text of what the operator must do — +not just `description`/`status` but also the finding and the how-to: + +```json +{ "checkpoint_id": "...", "description": "...", "status": "Manual", + "result": "", + "remediation": "" } +``` + +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`: + +``` +**** + + + + +``` + +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. +- 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. +- Do not mention `results.json`, file paths, or the tools used to produce it. + +--- + +## 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`. + +- If no such item exists, **stop and report** — a skill must not invent items. + The canonical item set lives in the checklist template + `src/skills/setup/workday-da/tasks.md`; a missing item means the template is + out of date, not that the updater should add one. +- If `STEP_ID` is **not** owned by the calling skill, **stop** — skills update + only their own items. + +--- + +## U.2 — Determine the new Status (the MANUAL/attestation rule) + +This is the load-bearing rule. **A `MANUAL` or attestation-gated row is never +auto-completed by a flightcheck pass.** + +Decide `Status` as follows: + +| `GATE` | Condition | Resulting `Status` | +|--------|-----------|--------------------| +| `prog` | `CHECKPOINT_RESULT` = `PASSED` | `done` | +| `prog` | `CHECKPOINT_RESULT` = `FAILED` | `blocked` | +| `prog` | `CHECKPOINT_RESULT` = `WARNING` / `null` | `in-progress` | +| `manual` / `attest` | `ACK` = `true` (user acknowledged **and** evidence captured) | `done` | +| `manual` / `attest` | `ACK` = `false`, regardless of `CHECKPOINT_RESULT` | `in-progress` (or `blocked` if `FAILED`) | +| `advisory` | the advisory step has been run and its report shown (or attempted and skipped) | `done` | + +Notes: +- An `advisory` row is not backed by a flightcheck checkpoint (`CHECKPOINT_RESULT` + is `null`). It **never blocks** — it completes to `done` once its advisory + output has been presented to the user, regardless of what the output found. If + the advisory step can't run, note it and still complete the row (advisory rows + never hold up the setup). +- A `CHECKPOINT_RESULT` of `MANUAL` means "the checkpoint reported what it could, + but completion needs a human." It **never** maps to `done` on its own — it + requires `ACK = true`. +- For `prog` rows, `NEW_STATE` from the caller must be consistent with + `CHECKPOINT_RESULT`; if they conflict, the checkpoint result wins (it's the + objective signal). + +If the row is `manual`/`attest` and `ACK` is `false`, before leaving the row +`in-progress` confirm the user actually saw the manual step. (Precondition: the +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.) + +```json +[ + { + "header": "Confirm step", + "question": "Have you completed this step and is the evidence captured?", + "options": [ + { "label": "Yes, it's done", "recommended": true }, + { "label": "Not yet" } + ], + "allowFreeformInput": false + } +] +``` + +Only treat the row as acknowledged (`ACK = true`) on an explicit "Yes, it's +done". Never infer acknowledgement from a flightcheck pass. + +--- + +## 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": "" + } + } + } + ``` + Merge — do not drop other `setupStatus` keys (round-trip contract in + `config-schema.md`). + +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 new file mode 100644 index 000000000..ede22866c --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/config-schema.md @@ -0,0 +1,152 @@ +# Workday DA Setup — Config Persistence Schema + +This file documents the **canonical shape** of the Workday connection config +that the `connect/workday-da` skill's steps read and write. It is a *reference +doc*, not an executable fragment — there are no Message blocks here. The steps +cite this file so they agree on field names, owners, and types. + +**Canonical data file:** `.local/connect/workday-da/config.json` + +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 four steps +(DA-1 install, DA-2 Entra, DA-3 tenant, DA-4 verify) instead of CEA's six. + +--- + +## Do NOT confuse the two config files + +There are **two distinct** files. Keep them separate. + +| File | Owner | Purpose | +|------|-------|---------| +| `.local/connect/workday-da/config.json` | the `connect/workday-da` skill | Workday connection state — URLs, tenant, Entra app, OAuth client, per-step status. **This schema.** | +| `.local/config.json` | foundation-setup + flightcheck | Agent identity, `dataverseEndpoint`, and the installed-agent inventory (`agent`/`agents`). `scripts/flightcheck/cli.py` reads this for the Dataverse endpoint. **Not this schema.** | + +Never write Workday connection fields into `.local/config.json`, and never +write agent identity / `dataverseEndpoint` into +`.local/connect/workday-da/config.json`. The only crossover is read-only: a +step may *read* `.local/config.json` for `dataverseEndpoint` / `agent.botId` +when it needs them. + +--- + +## Canonical fields + +All fields live at the top level of `.local/connect/workday-da/config.json` +unless noted. A field is written **once** by its owner step and thereafter +read by later steps. Unknown/absent fields are treated as `null`. + +### Connection + tenant (tenant URL captured early by DA-2; API-client fields by DA-3) + +| Field | Type | Owner | Notes | +|-------|------|-------|-------| +| `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. | +| `oauthTokenUrl` | string | DA-3 | `https://{tokenHost}/ccx/oauth2/{tenant}/token`. | +| `restBaseUrl` | string | DA-3 | REST base, **trimmed to `/api`** — see `shared/connection-fields.md`. | +| `soapBaseUrl` | string | DA-3 | SOAP base (`https://{services-host}/ccx/service`). | +| `domainName` | string | DA-3 | Workday domain name, when discovered. | +| `tenantId` | string | DA-2 | **Entra** tenant ID (GUID) — set during Entra setup. | +| `installPath` | string | DA-3 | `"simplified"`. | +| `status` | string | all | `"in-progress"` \| `"connected"`. | +| `vertical` | string | DA-1 | `"hr"` \| `"it"` — which DA base agent edition the Workday extension was installed against (from `WD-DA-PKG-001`). | + +### Entra app + OAuth client (owned by DA-2 / DA-3) + +| Field | Type | Owner | Notes | +|-------|------|-------|-------| +| `entraSSO` | boolean | DA-2 | True once the SSO gallery app + connector authorization exist. | +| `entraAppId` | string | DA-2 | Entra app (client) ID. | +| `entraAppObjectId` | string | DA-2 | Entra app object ID (for Graph calls). | +| `entraAppIdUri` / `appIdUri` | string | DA-2 | Application ID URI (`api://{entraAppId}`). `appIdUri` is the documented alias. | +| `scopeGuid` | string | DA-2 | GUID of the exposed `user_impersonation` scope. | +| `oauthClientId` | string | DA-3 | Workday API **client ID** (distinct from `entraAppId`). | +| `tokenEndpoint` | string | DA-3 | OAuth token endpoint captured from the Workday API client view. Mirrors `oauthTokenUrl` when both are present. | + +### Per-step status fields (owned by each step via the checklist-updater) + +Each step records its own checkpoint outcomes under a `setupStatus` object, +keyed by **Step ID** (`DA1.1` … `DA4.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 +same data. + +```json +{ + "setupStatus": { + "DA1.1": { "state": "done", "checkpoint": "WD-DA-PKG-001", "gate": "prog", "verifiedBy": "programmatic" }, + "DA2.1": { "state": "pending", "checkpoint": "WD-CONN-102", "gate": "attest", "verifiedBy": null } + } +} +``` + +- `state` ∈ `pending` \| `in-progress` \| `done` \| `blocked`. +- `gate` ∈ `prog` \| `manual` \| `attest` \| `advisory` (from the DA master + checklist row). +- `verifiedBy` ∈ `programmatic` \| `attested` \| `reviewed` \| `null`. A + `manual`/`attest` row is **never** set to `done` by a flightcheck pass + alone — it needs an explicit user acknowledgement plus captured evidence + (see `shared/checklist-updater.md` and `shared/permission-gate.md`, reused + unchanged from CEA). An `advisory` row (no checkpoint) completes with + `verifiedBy: "reviewed"` once its report has been shown; it never blocks. + +--- + +## Deferred (not tracked yet) + +The DA skill in this iteration does not track the following CEA-equivalent +fields — they are out of scope until a follow-up extends the connection +verification beyond installation: + +- `soapBaseUrl`/`restBaseUrl` binding verification against the live + connection references (CEA's `WD-CONN-AUTH-001`/`WD-REST-001`/`WD-NET-001` + equivalents) — DA-4 reports these as guided manual checks, not + programmatic ones, until DA-scoped checkpoints exist for the DA extension + package's connection references. +- `ootbTopics` — ready-made Workday topics. Topic installation for DA agents + is a separate, later piece of work; this skill does not offer it. + +--- + +## Full example (mid-setup) + +```json +{ + "baseUrl": "https://wd2-impl.workday.com", + "tenant": "acme_dpt1", + "tokenHost": "wd2-impl-services1.workday.com", + "oauthTokenUrl": "https://wd2-impl-services1.workday.com/ccx/oauth2/acme_dpt1/token", + "tokenEndpoint": "https://wd2-impl-services1.workday.com/ccx/oauth2/acme_dpt1/token", + "restBaseUrl": "https://wd2-impl-services1.workday.com/ccx/api", + "soapBaseUrl": "https://wd2-impl-services1.workday.com/ccx/service", + "tenantId": "00000000-0000-0000-0000-000000000000", + "installPath": "simplified", + "vertical": "hr", + "entraSSO": true, + "entraAppId": "11111111-1111-1111-1111-111111111111", + "entraAppObjectId": "22222222-2222-2222-2222-222222222222", + "appIdUri": "api://11111111-1111-1111-1111-111111111111", + "scopeGuid": "33333333-3333-3333-3333-333333333333", + "oauthClientId": "WORKDAY_CLIENT_ID", + "status": "in-progress", + "setupStatus": { + "DA1.1": { "state": "done", "checkpoint": "WD-DA-PKG-001", "gate": "prog", "verifiedBy": "programmatic" } + } +} +``` + +--- + +## Round-trip contract + +Any step that writes a field listed above must: + +1. **Read** the existing file first (it may already hold values from an + earlier step). +2. **Merge** — set only the fields it owns; never drop fields it doesn't own. +3. **Write** the merged object back. + +A value written by one step must read back identically in a later step (no +re-derivation, no format drift). The trim rules for `restBaseUrl` / +`soapBaseUrl` are defined once in `shared/connection-fields.md`. diff --git a/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md new file mode 100644 index 000000000..89b8abbc4 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/connection-fields.md @@ -0,0 +1,143 @@ +# Connection Fields — Capture & Validate (DA) + +Centralizes capture and validation of the Workday connection identifiers the +DA Workday setup skills exchange. **DA-3 captures** these (from the Workday +API client view and tenant URL); a later DA extension-pack configuration step +consumes them when it binds the connection. Keeping the rules here means both +steps agree on format — especially the documented **REST-base `/api` trim** +gotcha that silently breaks the connection if it's wrong. + +Forked from the CEA `setup/shared/connection-fields.md` with DA-scoped state +paths. The tenant math (URL derivation, trim rules) is agent-architecture +agnostic and identical to the CEA version — only the persisted file changes. + +Every **Message** block is the exact text to show the user. Copy it verbatim. Do +not rephrase or narrate tool calls. + +**Inputs from the calling file (any that are already known):** +- `WD_TENANT`, `WD_BASE_URL`, `WD_TOKEN_HOST` — captured by DA-3 from the + Workday tenant / API-client screens (or read from + `.local/connect/workday-da/config.json` when an earlier step already stored + them). +- `OAUTH_CLIENT_ID`, `TOKEN_ENDPOINT` — from the Workday "View API Client" + screen (DA-3). +- `APP_ID_URI` — the Entra Application ID URI (`api://{entraAppId}`) from + DA-2. + +**Outputs (written back to `.local/connect/workday-da/config.json`, see +`config-schema.md`):** +- `appIdUri`, `oauthTokenUrl` / `tokenEndpoint`, `oauthClientId`, + `soapBaseUrl`, `restBaseUrl` (trimmed). + +--- + +## C.1 — Application ID URI + +The Application ID URI identifies the Entra app registration itself +(`api://{entraAppId}`). DA-3 exposes it for the SAML token audience and the +connector's API pre-authorization. It is **not** the connection's "Microsoft +Entra resource URL" — see the note below. + +- Expected form: `api://{entraAppId}` (the GUID, not the object ID). +- If `APP_ID_URI` is missing, derive it from `entraAppId`: + `api://{entraAppId}`. +- **Validate:** must start with `api://` and contain a GUID. If it instead looks + like a full URL (`https://...`) or is empty, re-prompt: + +```json +[ + { + "header": "Application ID URI", + "question": "What's the Application ID URI of the Entra app? It looks like api://." + } +] +``` + +Save as `appIdUri`. + +> **Not the connection resource URL.** The Workday connection asks for a +> **Microsoft Entra resource URL** — the Workday SAML identifier +> `http://www.workday.com/{tenant}` (matching the Entra app's Identifier / +> Entity ID and Workday's SAML Service Provider ID), **not** this `api://…` App +> ID URI. + +--- + +## C.2 — OAuth token URL + +- Expected form: `https://{WD_TOKEN_HOST}/ccx/oauth2/{WD_TENANT}/token`. +- If `TOKEN_ENDPOINT` was captured from the API client screen, prefer it but + confirm it matches the derived form's host + tenant; if it diverges, keep the + captured value and note it. +- **Validate:** must be `https://`, contain `/ccx/oauth2/`, and end with `/token`. + +Save as `oauthTokenUrl` (and `tokenEndpoint` when captured from the API client). + +--- + +## C.3 — Client ID + +- `OAUTH_CLIENT_ID` is the **Workday API client ID** shown on the "View API + Client" screen. It is **not** the Entra `entraAppId` — do not conflate them. +- **Validate:** non-empty. If the user pastes something that is obviously the + Entra app GUID already stored as `entraAppId`, warn and re-ask — they are + distinct identities. + +Save as `oauthClientId`. + +--- + +## C.4 — SOAP base URL + +The SOAP base is derived from the Workday **services** host (the same host as +`WD_TOKEN_HOST`, so `https://{WD_TOKEN_HOST}/ccx/service` is equivalent): + +- `impl.workday.com` → `https://wd2-impl-services1.workday.com/ccx/service` +- `wd5.myworkday.com` → `https://wd5-services1.myworkday.com/ccx/service` +- `{dcN}.myworkday.com` → `https://{dcN}-services1.myworkday.com/ccx/service` + +- Expected form: `https://{services-host}/ccx/service` (no tenant suffix, no + trailing slash). +- **Validate:** must be `https://`, contain `/ccx/service`, and **not** end in a + trailing `/`. If `WD_BASE_URL` didn't match a known pattern, fall back to + asking the user for the SOAP base URL. + +Save as `soapBaseUrl`. + +--- + +## C.5 — REST base URL — trimmed to `/api` *(silent-failure gotcha)* + +This is the field that most often breaks the simplified-path connection. The +Workday screens and copy/paste sources frequently include extra trailing +segments. **Copy as displayed, then trim** so the value ends at `/api`. + +- Canonical form: `https://{WD_TOKEN_HOST}/ccx/api`. +- **Trim procedure** — starting from whatever was captured: + 1. Strip any trailing slash. + 2. If it ends with a version segment (`/v1`, `/v2`, …), remove it. + 3. If it ends with the tenant name or any path **after** `/ccx/api`, remove + everything after `/ccx/api`. + 4. The result must end exactly with `/ccx/api` (or `/api` for hosts that omit + `/ccx`). +- **Validate:** must be `https://`, contain `/api`, and have **nothing** after + the `/api` segment. If anything follows `/api`, trim it and show the user the + corrected value: + +**Message:** + +I trimmed the Workday REST base URL to **{restBaseUrl}** — the connection +fails silently if anything is appended after `/api`, so it has to end there. + +**End message.** + +Save the trimmed value as `restBaseUrl`. + +--- + +## C.6 — Persist + +Read `.local/connect/workday-da/config.json`, merge the validated fields above +(never dropping fields owned by other steps), and write it back — per the +round-trip contract in `config-schema.md`. Return the saved values to the +calling file. 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 new file mode 100644 index 000000000..a46c670d6 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/shared/permission-gate.md @@ -0,0 +1,146 @@ +# Permission Gate (Shared) + +A reusable **role check → specific named error → stop** routine. Every Workday +DA connect skill step applies this fragment before it performs role-restricted +work, so no step duplicates inline role logic. + +Forked from the CEA `setup/shared/permission-gate.md` — the role-gating logic +is identical; only the persisted-state path differs. + +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. + +**Inputs from the calling file:** +- `REQUIRED_ROLE` — the human-readable role name to require (e.g. + `"Workday Administrator"`, `"Power Platform Administrator"`, + `"Application Administrator"`). +- `GATE_MODE` — `"programmatic"` or `"attested"` (see "Choosing a mode" below). +- `STEP_ID` — the master-checklist Step ID this gate protects (e.g. `"DA3.1"`), + used only to record evidence. +- `ROLE_QUERY` — *(programmatic mode only)* the command/check that proves the + caller holds the role (the calling file supplies it; examples below). + +**Outputs to the calling file:** +- `GATE_RESULT` — `"pass"` or `"stop"`. On `"stop"`, the calling file must halt. +- `GATE_EVIDENCE` — an object recording how the gate was satisfied; the caller + persists it under `setupStatus["{STEP_ID}"].verifiedBy` in + `.local/connect/workday-da/config.json` (see `config-schema.md`): + - `verifiedBy` ∈ `"programmatic"` \| `"attested"`. + - `note` — short free text (e.g. the role-query result, or the user's + attestation timestamp/identity). + +--- + +## Choosing a mode + +The gating mechanism differs by role because not every role has a queryable +directory: + +| Role family | Mode | How verified | +|-------------|------|--------------| +| Entra roles (App Admin, Cloud App Admin, Global Admin, Priv Role Admin) | `programmatic` | Microsoft Graph role / privilege query | +| 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 | + +The calling file picks `GATE_MODE` from this table. **Never** silently pass an +attested role — always require the explicit confirmation in section G.2. + +--- + +## G.1 — Programmatic gate + +Use when `GATE_MODE` is `"programmatic"`. + +Run the `ROLE_QUERY` the calling file supplied. Examples of what a caller passes: + +- **Entra role (Graph):** + ``` + az rest --method GET --url "https://graph.microsoft.com/v1.0/me/memberOf?%24select=displayName" --query "value[].displayName" -o json + ``` + (OData options are percent-encoded — `%24select` not `$select` — so the URL + survives PowerShell/bash `$`-expansion and runs first-try on every shell.) + Pass if the result contains a directory role that grants `REQUIRED_ROLE` + (e.g. `Application Administrator`, `Cloud Application Administrator`, + `Global Administrator`). +- **Power Platform Admin / Dataverse role:** the caller supplies the specific + admin-API or Dataverse query and the expected value. + +**If the query proves the role is held:** +- Set `GATE_RESULT = "pass"`. +- Set `GATE_EVIDENCE = { "verifiedBy": "programmatic", "note": "" }`. +- Return to the calling file. + +**If the query proves the role is NOT held** (or returns an +`Insufficient privileges` / `Authorization_RequestDenied` error — mirror the +existing pattern in `connect/azure/app-registration.md` section B.2): + +**Message:** + +This step requires the **{REQUIRED_ROLE}** role, and your account doesn't +have it. Ask your administrator to grant this role, then come back and run +this step again. + +**End message.** + +- Set `GATE_RESULT = "stop"`. +- Return to the calling file. **The caller must halt — do not proceed.** + +**If the query itself fails** for an unrelated reason (network, not logged in): +retry once. If it still fails, **do not** assume pass — fall back to the +attestation gate in G.2 (so a check error never silently grants access), +recording `note` = the query error. + +--- + +## G.2 — Attestation gate + +Use when `GATE_MODE` is `"attested"` (Workday Administrator, InfoSec/IT), or as +the fallback when a programmatic query errored. + +**Message:** + +This step requires the **{REQUIRED_ROLE}** role. I can't verify that +automatically for this system, so I need you to confirm you (or the person +doing this step) hold that role before we continue. + +**End message.** + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "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": "No / not sure" } + ], + "allowFreeformInput": false + } +] +``` + +**If the user chose "Yes, I have this role":** +- Set `GATE_RESULT = "pass"`. +- Set `GATE_EVIDENCE = { "verifiedBy": "attested", "note": "user attested {REQUIRED_ROLE} for {STEP_ID}" }`. +- Return to the calling file. + +**If the user chose "No / not sure":** + +**Message:** + +No problem — this step needs the **{REQUIRED_ROLE}** role. Ask whoever holds +that role to run it, then come back and continue. + +**End message.** + +- Set `GATE_RESULT = "stop"`. +- Return to the calling file. **The caller must halt — do not proceed.** + +> An attested `"pass"` records that the role was **claimed**, not directory-proven. +> It satisfies the *gate*, but it does **not** by itself complete the checklist row +> — the row still needs its own captured evidence/acknowledgement per +> `checklist-updater.md`. 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 new file mode 100644 index 000000000..47a4405c3 --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/tasks.md @@ -0,0 +1,102 @@ + +# Workday Connect (DA) — Checklist (template) + +The single, trackable checklist spanning the four Workday connect steps for the +**Declarative Agent (DA)** flavor of Employee Self-Service. 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** +through the shared +[`shared/checklist-updater.md`](shared/checklist-updater.md). The durable +mirror of each item's status is `setupStatus` in +`.local/connect/workday-da/config.json` (see +[`shared/config-schema.md`](shared/config-schema.md)). + +> Do not hand-edit the working copy's checkboxes — let the checklist-updater +> write them so the **MANUAL / attestation rule** is enforced in one place. + +This checklist assumes your DA Employee Self-Service base agent is already +installed (via `/setup`). If it isn't, DA-1 below detects that and points you +there first. + +## How to read this checklist + +Each item is a plain checkbox with a short description of what it achieves — +that is what the user sees: + +- `- [ ]` — not done yet. +- `- [x]` — done. + +The technical details the tooling needs (the stable **Step ID**, the +flightcheck **checkpoint(s)** that verify the item, and the completion +**gate**) live in the HTML comment directly under each item. Those comments are +invisible in the rendered checklist; only the checklist-updater reads them. +**Never surface a Step ID or checkpoint ID to the user** — show the checkbox +and its description only. + +**Gate** — how an item reaches done: + +| Gate | Meaning | +|------|---------| +| `prog` | A programmatic flightcheck pass completes the item. | +| `manual` | Explicit user action + re-verify; a flightcheck pass alone never completes it. | +| `attest` | Attestation + captured evidence (no queryable directory); never auto-completed. | +| `advisory` | Informational; completes once its output has been shown, regardless of findings. | + +The hidden `status:` field carries the full four-state value +(`pending` \| `in-progress` \| `done` \| `blocked`) that a single checkbox can't +express; all items start `pending`. + +## Checklist + +### 1. Workday extension package + +- [ ] **Install the Workday extension package** — Add the Workday extension package to your DA agent so it can talk to Workday. If your DA base agent isn't installed yet, this step sends you to `/setup` first. + + +### 2. Workday single sign-on (Entra) + +- [ ] **Create the Workday single sign-on app** — Set up the Entra SSO application for Workday in SAML mode, with the right sign-on URLs and an active signing certificate. + +- [ ] **Expose the Workday API permission** — Publish the sign-in scope, pre-authorize the Workday connector, and request the Microsoft Graph permissions the agent needs. + +- [ ] **Grant admin consent** — Approve the requested Microsoft Graph permissions on behalf of your organization. + +- [ ] **Assign users to the Workday app** — Give the right people or groups access to the Workday enterprise application, or confirm assignment isn't required. + +- [ ] **Map the sign-in identifier** — Configure the NameID claim so Workday recognizes each signed-in employee. + +- [ ] **Set the SAML signing option** — Turn on "Sign SAML response and assertion" so Workday trusts the sign-in tokens. + +- [ ] **Confirm a single sign-in tenant** — Verify Workday and Entra are federated to the same single tenant so sign-in lines up. + + +### 3. Workday tenant configuration + +- [ ] **Register the Workday API client** — In Workday, register the API client for the agent, including the functional areas and Workday-owned scope. + +- [ ] **Capture your Workday connection details** — Record the client ID, token endpoint, REST and SOAP base URLs, and tenant name needed to connect. + +- [ ] **Activate the Workday authentication policy** — Scope Workday's authentication policy to the new OAuth client, allow SAML sign-in, and activate it. + +- [ ] **Match the signing certificate** — Confirm the Workday-side signing certificate matches the one in Entra (validity dates, or an externally-computed SHA-1 — Workday shows no thumbprint). + + +### 4. Verify your Workday connection + +- [ ] **Review your Workday connection** — Confirm the extension package, single sign-on, and tenant configuration are all in place, and see what's left before your agent can use Workday. + + +> An item backed by an **attest** or **manual** gate is **never** auto-completed +> by its checkpoint — it requires an explicit user acknowledgement plus +> captured evidence (see [`shared/checklist-updater.md`](shared/checklist-updater.md)). + +## Deferred to a follow-up + +This checklist deliberately stops at "the extension package is installed and +your Entra/tenant configuration is in place." It does not yet include DA +checkpoints for binding the Workday connection references (account sign-in, +Dataverse connection, REST address, cloud flows, firewall allowlisting) the +way the CEA `setup/workday/tasks.md` skills 5.2–5.8 do — those checks are keyed +off connection-reference and cloud-flow internals specific to the CEA package +today, and DA-scoped equivalents don't exist yet. DA-4 tells you this +explicitly and points you to the full FlightCheck report so nothing is hidden. 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 new file mode 100644 index 000000000..9b934334e --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/verify-connection.md @@ -0,0 +1,82 @@ + +# DA-4 — Verify Your Workday Connection + +Role: **Environment Maker**. This step reviews everything the earlier steps +verified, re-confirms the extension package is still installed, and gives an +honest summary of what this skill does — and does not yet — check about the +live Workday connection. It owns master-checklist row **DA4.1** (an `advisory` +row: it completes once shown, regardless of what it finds). + +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. + +--- + +## DA4.1 — Review your Workday connection + +**Re-confirm the extension package.** + +``` +python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 +``` + +Show the result per [`shared/checklist-updater.md`](shared/checklist-updater.md) +§U.0. + +**Summarize the Entra and tenant configuration recorded so far.** Read +`.local/connect/workday-da/config.json` and render what's known: + +**Message:** + +Here's where your Workday connection stands: + +| Area | Status | +| --- | --- | +| Workday extension package | {✅/❌ from WD-DA-PKG-001} | +| Workday single sign-on (Entra) | {✅ if DA2.1–DA2.7 are all `done`, else "in progress"} | +| Workday tenant configuration | {✅ if DA3.1–DA3.4 are all `done`, else "in progress"} | + +**End message.** + +If any of DA1.1, DA2.1–DA2.7, or DA3.1–DA3.4 is not `done`, tell the user which +step to finish and stop here — do not present the connection as ready. + +**If everything above is done, be transparent about what this skill has — and +has not — verified.** This skill confirms the extension package is installed and +your Entra/tenant configuration is in place. It does not yet run DA-scoped +checks against the Workday extension package's live connection references (the +Workday account sign-in, the Dataverse connection, the REST address, the cloud +flows, or the firewall allowlist) — those checks exist for the CEA Employee +Self-Service agent today (`WD-CONN-AUTH-001`, `DV-CONN-001`, `WD-REST-001`, +`WD-FLOW-*`, `WD-NET-001`) but are not yet built for the DA extension package. + +**Message:** + +Your Workday extension package, single sign-on, and tenant configuration are all +in place. One thing to know: this skill doesn't yet automatically verify the +live connection inside the extension package itself — things like the account +sign-in, the REST address, or your firewall allowlist. To finish confirming your +agent can reach Workday: + +1. Open the [Power Apps maker portal](https://make.powerapps.com), select this + environment, and confirm the Workday connection shows as **Connected** + under **Connections**. +2. Try a Workday scenario with your agent (for example, checking a vacation + balance) and confirm it returns real data. +3. If it doesn't, run `python scripts/flightcheck/cli.py --scope workdayda` for + the full report, or reach out to your Workday administrator to recheck the + tenant configuration above. + +**End message.** + +Update **DA4.1** via [`shared/checklist-updater.md`](shared/checklist-updater.md) +with `STEP_ID="DA4.1"`, `GATE="advisory"`, `CHECKPOINT_RESULT` = the +`WD-DA-PKG-001` result — an advisory row completes once shown, regardless of +findings. + +--- + +## Done + +Return control to the orchestrator (`SKILL.md`) — every row should now be `done`. diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py new file mode 100644 index 000000000..604aad15c --- /dev/null +++ b/tests/flightcheck/checks/test_workday_da.py @@ -0,0 +1,249 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""End-to-end tests for WD-DA-PKG-001 (DA Workday extension package +installed) in +``solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py``. + +Mocks the single Dataverse Web API endpoint the check calls (the +``solutions`` table query) with the ``responses`` library, then invokes the +real production helper ``_check_workday_da_package_installed`` and asserts +on the resulting ``CheckResult``. Mirrors the pattern in +``tests/flightcheck/checks/test_solution.py``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest +import responses + +from tests.conftest import FAKE_DATAVERSE_URL, require_validated_mock +from tests.mocks import dataverse as dv + +require_validated_mock(dv) + + +# 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, +) + + +BASE_URL = FAKE_DATAVERSE_URL + +# Verbatim from the production check; if these drift, mock-builder URLs will +# stop matching and tests fail loudly with an unregistered-URL error. +SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" +SOLN_FILTER = ( + "uniquename eq 'msdyn_copilotforemployeeselfservicedahr' or " + "uniquename eq 'msdyn_copilotforemployeeselfservicedait' or " + "uniquename eq 'msdyn_essdahrworkday' or " + "uniquename eq 'msdyn_essdaitworkday'" +) + +SOLUTION_ID = "22222222-2222-2222-2222-222222222222" + + +# ─────────────────────────────────────────────────────────────────────── +# Minimal runner — mirrors the pattern in test_solution.py. +# ─────────────────────────────────────────────────────────────────────── + + +@dataclass +class _MinimalRunner: + env_url: str | None + dv_token: str | None + + +@pytest.fixture +def runner(fake_dataverse_url: str, fake_token: str) -> _MinimalRunner: + return _MinimalRunner(env_url=fake_dataverse_url, dv_token=fake_token) + + +# ─────────────────────────────────────────────────────────────────────── +# Mock payload builders + registration helpers +# ─────────────────────────────────────────────────────────────────────── + + +def _solution_record( + uniquename: str, + *, + version: str = "1.0.0.0", + ismanaged: bool = True, +) -> dict[str, Any]: + return { + "@odata.etag": 'W/"1"', + "solutionid": SOLUTION_ID, + "uniquename": uniquename, + "friendlyname": uniquename, + "ismanaged": ismanaged, + "version": version, + } + + +def _register_solutions(solutions: list[dict[str, Any]]) -> None: + responses.add(**dv.query( + base_url=BASE_URL, + entity_set="solutions", + records=solutions, + select=SOLN_SELECT, + filter_expr=SOLN_FILTER, + )) + + +# ─────────────────────────────────────────────────────────────────────── +# Tests — one per verdict path. +# ─────────────────────────────────────────────────────────────────────── + + +def test_skipped_when_env_url_missing() -> None: + results = _check_workday_da_package_installed( + _MinimalRunner(env_url=None, dv_token="tok") + ) + assert len(results) == 1 + r = results[0] + 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 + + +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" + + +@responses.activate +def test_failed_when_no_da_base_agent(runner: _MinimalRunner) -> None: + _register_solutions(solutions=[]) + + results = _check_workday_da_package_installed(runner) + assert len(results) == 1 + r = results[0] + assert r.checkpoint_id == "WD-DA-PKG-001" + assert r.status == "Failed" + assert "No DA Employee Self-Service base agent" in r.result + assert "/setup" in r.remediation + + +@responses.activate +def test_failed_when_hr_base_agent_present_but_workday_child_missing( + runner: _MinimalRunner, +) -> None: + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Failed" + assert "HR" in r.result + assert "AppSource" in r.remediation + + +@responses.activate +def test_failed_when_it_base_agent_present_but_workday_child_missing( + runner: _MinimalRunner, +) -> None: + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedait"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Failed" + assert "IT" in r.result + + +@responses.activate +def test_passed_when_hr_workday_child_present(runner: _MinimalRunner) -> None: + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + _solution_record("msdyn_essdahrworkday", version="2.0.0.1"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Passed" + assert "msdyn_essdahrworkday" in r.result + assert "2.0.0.1" in r.result + # Principle: PASSED carries no remediation. + assert r.remediation == "" + + +@responses.activate +def test_passed_when_it_workday_child_present(runner: _MinimalRunner) -> None: + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedait"), + _solution_record("msdyn_essdaitworkday"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Passed" + assert "msdyn_essdaitworkday" in r.result + + +@responses.activate +def test_failed_lists_already_installed_vertical_when_other_missing( + runner: _MinimalRunner, +) -> None: + """Both HR and IT base agents installed; only HR has its Workday child.""" + _register_solutions(solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + _solution_record("msdyn_copilotforemployeeselfservicedait"), + _solution_record("msdyn_essdahrworkday"), + ]) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Failed" + assert "IT" in r.result + assert "Already installed" in r.result + assert "msdyn_essdahrworkday" in r.result + + +@responses.activate +def test_warning_when_dataverse_returns_500(runner: _MinimalRunner) -> None: + """A transient platform error must surface as WARNING, not silently PASS.""" + responses.add( + "GET", + dv.build_query_url( + BASE_URL, + "solutions", + select=SOLN_SELECT, + filter_expr=SOLN_FILTER, + ), + json={"error": {"code": "0x80040220", "message": "boom"}}, + status=500, + ) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Warning" + assert "Unable to verify the DA Workday package" in r.result + + +@responses.activate +def test_warning_when_dataverse_returns_401(runner: _MinimalRunner) -> None: + """A 401 must surface as WARNING with an auth-expired hint. + + Exercises the AuthExpiredError catch block in + _check_workday_da_package_installed. + """ + responses.add( + "GET", + dv.build_query_url( + BASE_URL, + "solutions", + select=SOLN_SELECT, + filter_expr=SOLN_FILTER, + ), + json={"error": {"code": "0x80048306", "message": "token expired"}}, + status=401, + ) + + r = _check_workday_da_package_installed(runner)[0] + assert r.status == "Warning" + assert "401" in r.result + assert "Re-run FlightCheck" in r.remediation diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py new file mode 100644 index 000000000..0ff03263b --- /dev/null +++ b/tests/scripts/test_install_workday_da_extension.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for the DA Workday extension package installer. + +Mirrors the mocking pattern in ``tests/scripts/test_install_ess_agent.py``: +fake ``PPAdminClient``/``PowerPlatformClient`` factories stand in for the +real Power Platform REST APIs, and ``discover_tenant`` is patched so no +network call is made. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + + +class FakePPAdminClient: + def __init__(self, tenant_id, environment_id="env-123"): + self.tenant_id = tenant_id + self.environment_id = environment_id + self.authenticated = False + + def authenticate(self, *, include_flow=True): + self.authenticated = True + self.include_flow = include_flow + + def find_environment_id_by_dataverse_url(self, _env_url): + return self.environment_id + + +class FakePowerPlatformClient: + def __init__(self, tenant_id, *, packages, install_result=None): + self.tenant_id = tenant_id + self.packages = list(packages) + self.install_result = install_result or {} + self.authenticated = False + self.install_calls = [] + + def authenticate(self): + self.authenticated = True + + def list_environment_application_packages(self, _environment_id): + if self.packages and isinstance(self.packages[0], list): + return self.packages.pop(0) + return self.packages + + def install_application_package(self, environment_id, unique_name): + self.install_calls.append((environment_id, unique_name)) + return self.install_result + + +@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") +def test_not_listed_when_marketplace_catalog_has_no_match(_mock_discover_tenant): + import install_workday_da_extension as m + + powerplatform = FakePowerPlatformClient("tenant-123", packages=[]) + + with pytest.raises(m.ExtensionNotListedError, match="msdyn_essdahrworkday"): + m.install_workday_da_extension( + "https://org.crm.dynamics.com", + "hr", + pp_admin_client_factory=FakePPAdminClient, + powerplatform_client_factory=lambda _tenant: powerplatform, + ) + + # Never attempts an install call against a package that isn't listed. + assert powerplatform.install_calls == [] + + +@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") +def test_skips_install_when_already_installed(_mock_discover_tenant): + import install_workday_da_extension as m + + powerplatform = FakePowerPlatformClient( + "tenant-123", + packages=[{"uniqueName": "msdyn_essdaitworkday", "state": "Installed"}], + ) + + schema = m.install_workday_da_extension( + "https://org.crm.dynamics.com", + "it", + pp_admin_client_factory=FakePPAdminClient, + powerplatform_client_factory=lambda _tenant: powerplatform, + ) + + assert schema == "msdyn_essdaitworkday" + assert powerplatform.install_calls == [] + + +@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") +def test_installs_and_polls_until_installed(_mock_discover_tenant): + import install_workday_da_extension as m + + schema_name = "msdyn_essdahrworkday" + powerplatform = FakePowerPlatformClient( + "tenant-123", + packages=[ + [{"uniqueName": schema_name, "state": "None"}], + [{"uniqueName": schema_name, "state": "Installing"}], + [{"uniqueName": schema_name, "state": "Installed"}], + ], + install_result={"_operationId": "operation-123"}, + ) + + schema = m.install_workday_da_extension( + "https://org.crm.dynamics.com", + "hr", + pp_admin_client_factory=FakePPAdminClient, + powerplatform_client_factory=lambda _tenant: powerplatform, + poll_interval_seconds=0, + sleep=lambda _seconds: None, + ) + + assert schema == schema_name + assert powerplatform.install_calls == [("env-123", schema_name)] + + +@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") +def test_times_out_and_reports_last_status(_mock_discover_tenant): + import install_workday_da_extension as m + + schema_name = "msdyn_essdaitworkday" + powerplatform = FakePowerPlatformClient( + "tenant-123", + packages=[ + [{"uniqueName": schema_name, "state": "None"}], + [{"uniqueName": schema_name, "state": "Installing"}], + [{"uniqueName": schema_name, "state": "Installing"}], + ], + install_result={"_operationId": "operation-123"}, + ) + now = [0] + + def sleep(seconds): + now[0] += seconds + + with pytest.raises(m.InstallationTimeoutError, match="10 minutes"): + m.install_workday_da_extension( + "https://org.crm.dynamics.com", + "it", + pp_admin_client_factory=FakePPAdminClient, + powerplatform_client_factory=lambda _tenant: powerplatform, + poll_interval_seconds=300, + sleep=sleep, + clock=lambda: now[0], + ) + + +@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") +def test_reports_install_permission_failure(_mock_discover_tenant): + import install_workday_da_extension as m + + schema_name = "msdyn_essdahrworkday" + powerplatform = FakePowerPlatformClient( + "tenant-123", + packages=[{"uniqueName": schema_name, "state": "None"}], + install_result={"_error": "insufficient_permissions", "_status": 403}, + ) + + with pytest.raises(RuntimeError, match="cannot install"): + m.install_workday_da_extension( + "https://org.crm.dynamics.com", + "hr", + pp_admin_client_factory=FakePPAdminClient, + powerplatform_client_factory=lambda _tenant: powerplatform, + ) From caef2ed10882e7b302a67a57b1d341fea1c42692 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Thu, 17 Sep 2026 10:50:12 -0700 Subject: [PATCH 04/17] [ESS DA GA] Connect Workday Skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds  /connect workday  support for ESS DA agents, including Workday extension package detection and installation, Entra and tenant configuration guidance, resumable validation, and HR/IT multi-vertical support. It also introduces architecture-aware routing and safer per-agent lifecycle handling for existing CEA Workday integrations. --- .../ess-maker-skills/scripts/checkpoint.py | 112 +++++++ .../checks/_workday_app_assignment.py | 13 +- .../scripts/flightcheck/checks/workday_da.py | 7 +- .../flightcheck/checks/workday_extension.py | 34 ++- .../flightcheck/checks/workday_tenant.py | 8 +- .../scripts/flightcheck/cli.py | 77 +++++ .../src/skills/connect/SKILL.md | 29 +- .../shared/lifecycle-contract-schema.md | 26 +- .../skills/connect/shared/lifecycle-runner.md | 64 +++- .../src/skills/connect/step1.md | 71 +++-- .../src/skills/connect/steps.md | 6 +- .../src/skills/connect/workday/SKILL.md | 6 +- .../actions/wire-user-context-redirect.md | 42 ++- .../src/skills/connect/workday/contract.json | 7 +- .../src/skills/setup/workday-da/SKILL.md | 15 +- .../setup/workday-da/configure-tenant.md | 6 +- .../setup/workday-da/install-extension.md | 47 +-- .../setup/workday-da/provision-entra-app.md | 14 +- .../workday-da/shared/checklist-updater.md | 2 +- .../setup/workday-da/shared/config-schema.md | 6 +- .../src/skills/setup/workday-da/tasks.md | 6 +- .../setup/workday-da/verify-connection.md | 34 ++- tests/flightcheck/checks/test_entra_app.py | 15 + tests/flightcheck/checks/test_workday_da.py | 2 + .../checks/test_workday_extension.py | 37 +++ .../flightcheck/test_cli_single_checkpoint.py | 103 +++++++ tests/scripts/test_checkpoint.py | 90 ++++++ tests/setup/test_setup_router.py | 287 +++++++++++++++--- 28 files changed, 976 insertions(+), 190 deletions(-) create mode 100644 tests/scripts/test_checkpoint.py diff --git a/solutions/ess-maker-skills/scripts/checkpoint.py b/solutions/ess-maker-skills/scripts/checkpoint.py index d8063f7a3..c19454201 100644 --- a/solutions/ess-maker-skills/scripts/checkpoint.py +++ b/solutions/ess-maker-skills/scripts/checkpoint.py @@ -10,6 +10,7 @@ Usage: python scripts/checkpoint.py "reason for checkpoint" python scripts/checkpoint.py --revert + python scripts/checkpoint.py --revert-reason "reason for checkpoint" [--only "glob"] python scripts/checkpoint.py --baseline python scripts/checkpoint.py --list """ @@ -19,6 +20,7 @@ import shutil import sys import time +from glob import glob from datetime import datetime, timezone EXCLUDE_DIRS = {".baseline", ".checkpoints"} @@ -103,6 +105,45 @@ def restore_from(agent_dir, source_dir): shutil.copy2(src, dst) +def restore_matching(agent_dir, source_dir, pattern): + """Restore only paths matching pattern from a checkpoint.""" + source_root = os.path.abspath(source_dir) + agent_root = os.path.abspath(agent_dir) + source_pattern = os.path.abspath(os.path.join(source_root, pattern)) + agent_pattern = os.path.abspath(os.path.join(agent_root, pattern)) + if os.path.commonpath([source_root, source_pattern]) != source_root: + raise ValueError(f"Restore pattern escapes checkpoint: {pattern}") + if os.path.commonpath([agent_root, agent_pattern]) != agent_root: + raise ValueError(f"Restore pattern escapes agent folder: {pattern}") + + source_matches = { + os.path.relpath(path, source_root) + for path in glob(source_pattern, recursive=True) + } + current_matches = { + os.path.relpath(path, agent_root) + for path in glob(agent_pattern, recursive=True) + } + if not source_matches and not current_matches: + raise ValueError(f'Restore pattern matched no paths: "{pattern}"') + + for relative_path in sorted(source_matches | current_matches): + source_path = os.path.abspath(os.path.join(source_root, relative_path)) + target_path = os.path.abspath(os.path.join(agent_root, relative_path)) + + if os.path.isdir(target_path): + shutil.rmtree(target_path) + elif os.path.exists(target_path): + os.remove(target_path) + + if os.path.isdir(source_path): + os.makedirs(os.path.dirname(target_path), exist_ok=True) + shutil.copytree(source_path, target_path) + elif os.path.isfile(source_path): + os.makedirs(os.path.dirname(target_path), exist_ok=True) + shutil.copy2(source_path, target_path) + + def create_checkpoint(agent_dir, reason): """Create a new checkpoint of current working files. Returns the number.""" checkpoints_dir = get_checkpoints_dir(agent_dir) @@ -153,6 +194,53 @@ def cmd_revert(agent_dir): print(f"Reverted to checkpoint {target}.") +def cmd_revert_reason(agent_dir, reason, only=None): + """Restore the newest checkpoint whose metadata reason exactly matches.""" + checkpoints_dir = get_checkpoints_dir(agent_dir) + if not os.path.exists(checkpoints_dir): + print("ERROR: No checkpoints exist. Nothing to revert.") + sys.exit(1) + + matches = [] + for entry in os.listdir(checkpoints_dir): + if not entry.isdigit() or not os.path.isdir( + os.path.join(checkpoints_dir, entry) + ): + continue + meta_path = os.path.join(checkpoints_dir, entry, "_meta.json") + try: + with open(meta_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except (OSError, ValueError): + continue + if meta.get("reason") == reason: + matches.append(int(entry)) + + if not matches: + print(f'ERROR: No checkpoint found with reason: "{reason}"') + sys.exit(1) + + save_num = create_checkpoint(agent_dir, "auto-save before named revert") + print(f"Checkpoint {save_num} created: auto-save before named revert") + + target = max(matches) + source_dir = os.path.join(checkpoints_dir, str(target)) + if only: + source_root = os.path.abspath(source_dir) + source_pattern = os.path.abspath(os.path.join(source_root, only)) + if os.path.commonpath([source_root, source_pattern]) != source_root: + raise ValueError(f"Restore pattern escapes checkpoint: {only}") + + if only: + restore_matching(agent_dir, source_dir, only) + print( + f'Restored "{only}" from checkpoint {target}: "{reason}".' + ) + else: + restore_from(agent_dir, source_dir) + print(f'Reverted to checkpoint {target}: "{reason}".') + + def cmd_baseline(agent_dir): baseline_dir = get_baseline_dir(agent_dir) if not os.path.exists(baseline_dir): @@ -210,6 +298,8 @@ def main(): print("Usage:") print(' checkpoint.py "reason" — Create a checkpoint') print(" checkpoint.py --revert — Revert to last checkpoint") + print(' checkpoint.py --revert-reason "reason" [--only "glob"] ' + '— Revert all or matching paths to a named checkpoint') print(" checkpoint.py --baseline — Restore original environment state") print(" checkpoint.py --list — List all checkpoints") sys.exit(1) @@ -218,6 +308,28 @@ def main(): if arg == "--revert": cmd_revert(agent_dir) + elif arg == "--revert-reason": + if len(sys.argv) < 3: + print("ERROR: --revert-reason requires an exact checkpoint reason.") + sys.exit(1) + extra_args = sys.argv[2:] + only = None + if "--only" in extra_args: + only_index = extra_args.index("--only") + if only_index == len(extra_args) - 1: + print("ERROR: --only requires a path or glob.") + sys.exit(1) + only = extra_args[only_index + 1] + reason_parts = extra_args[:only_index] + if len(extra_args) != only_index + 2: + print("ERROR: Unexpected arguments after --only.") + sys.exit(1) + else: + reason_parts = extra_args + if not reason_parts: + print("ERROR: --revert-reason requires an exact checkpoint reason.") + sys.exit(1) + cmd_revert_reason(agent_dir, " ".join(reason_parts), only=only) elif arg == "--baseline": cmd_baseline(agent_dir) elif arg == "--list": diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py index b0b75eeac..2d4e757f7 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/_workday_app_assignment.py @@ -86,12 +86,11 @@ def _workday_hints(config) -> tuple[str, str]: and ``entraAppObjectId`` steers the application lookup. The config-schema documents both as top-level keys of ``.local/config.json`` — the file FlightCheck loads into ``runner.config`` — so that source wins. In - practice the connect / setup playbooks currently persist them to - ``.local/connect/workday/config.json`` instead, which the runner never - loads; without a fallback the hint is always empty at runtime and the - consent / assignment / NameID checks silently validate ``sps[0]`` (an - arbitrary sibling Workday app). We therefore fall back to that connect - config. Any read/parse error degrades to empty hints (→ unscoped / + practice older callers may omit an explicit provider overlay, so they fall + back to ``.local/connect/workday/config.json``. When ``--connect-config`` + was supplied, that explicit architecture-specific file is authoritative: + missing values remain missing rather than bleeding in from CEA state. + Any fallback read/parse error degrades to empty hints (→ unscoped / ``sps[0]`` behavior), never raising — FlightCheck emitters must not throw. """ cfg = config or {} @@ -99,6 +98,8 @@ def _workday_hints(config) -> tuple[str, str]: obj_id = str(cfg.get("entraAppObjectId") or "").strip() if app_id and obj_id: return app_id, obj_id + if cfg.get("_connectConfigPath"): + return app_id, obj_id try: connect_path = os.path.join(".local", "connect", "workday", "config.json") with open(connect_path, encoding="utf-8") as f: 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 832645cee..a53bd0969 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -120,6 +120,9 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: for vertical, parent_schema in _DA_PARENT_SCHEMAS.items() if parent_schema in installed_names ] + installed_labels = ", ".join( + _VERTICAL_LABEL[vertical] for vertical in installed_verticals + ) if not installed_verticals: return [_result( @@ -150,7 +153,8 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: Status.FAILED.value, f"The Workday extension package is not installed for the " f"{missing_labels} edition of your DA Employee Self-Service " - f"agent.{installed_summary}", + f"agent. Detected DA base agent editions: " + f"{installed_labels}.{installed_summary}", remediation=( "Open AppSource (or the Microsoft 365 admin center), find " "the Workday extension for Employee Self-Service, and " @@ -162,6 +166,7 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: return [_result( Status.PASSED.value, + f"Detected DA base agent editions: {installed_labels}. " f"DA Workday extension package installed: {'; '.join(findings)}.", )] diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py index 9f023bc01..f41ca7858 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_extension.py @@ -583,10 +583,36 @@ def _check_user_context_redirect(runner) -> list[CheckResult]: ), )] - agent_dirs = sorted( - d for d in agents_root.iterdir() - if d.is_dir() and not d.name.startswith(".") - ) + agent_slug = str( + getattr(runner, "agent_slug", "") + or config.get("activeAgent") + or (config.get("agent") or {}).get("slug") + or "" + ).strip() + if agent_slug: + target = agents_root / agent_slug + if not target.is_dir(): + return [CheckResult(roles=_MAKER_ROLES, + checkpoint_id="WD-REST-002", category=_CATEGORY, + priority=Priority.HIGH.value, + status=Status.NOT_CONFIGURED.value, + description=_REDIRECT_DESC, + result=( + f"No local workspace found for the active agent " + f"'{agent_slug}'." + ), + remediation=( + "Refresh the active agent with fetch_and_setup, then rerun " + "the checkpoint." + ), + doc_link=_DOC_SIMPLIFIED, + )] + agent_dirs = [target] + else: + agent_dirs = sorted( + d for d in agents_root.iterdir() + if d.is_dir() and not d.name.startswith(".") + ) topic_files = [ (d.name, d / "topics" / _USER_CONTEXT_FILE) for d in agent_dirs diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py index c5a637d81..26cf8808b 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_tenant.py @@ -24,9 +24,9 @@ reach, and standing up a Workday connection to self-verify would be circular (it needs the same Entra-app + tenant config the ESS agent itself needs). So both checkpoints emit MANUAL attestations — they echo - whatever the operator captured into ``.local/connect/workday/config.json`` - and name the exact Workday admin screen to verify. A MANUAL row never - fails readiness and never auto-completes an attest row. + whatever the operator captured into the provider config explicitly merged + into ``runner.config`` and name the exact Workday admin screen to verify. + A MANUAL row never fails readiness and never auto-completes an attest row. * **Never raise** — the dispatcher wraps every emitter so an unexpected failure degrades to a WARNING for that checkpoint instead of aborting the whole run. @@ -125,7 +125,7 @@ def _check_api_client(config) -> list[CheckResult]: else: result = ( "Workday admin task — no Workday API client has been captured yet " - "(oauthClientId is empty in .local/connect/workday/config.json). " + "(oauthClientId is empty in the active Workday connect config). " "Register the API client and capture its Client ID and Token " "Endpoint from the 'View API Client' screen before this row can " "be attested." diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 669b64d44..70377e0fb 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -710,6 +710,41 @@ def _is_native_no_dataverse(config: dict, env_url: str) -> bool: return str(active.get("releaseLine") or "").casefold() == "da" +def _merge_connect_config(config: dict, connect_config_path: str | None) -> dict: + """Overlay provider-owned validation fields onto foundation config. + + Provider connect state intentionally lives outside ``.local/config.json``. + An explicit path keeps CEA and DA Workday state from being guessed or + merged together when both exist in the same workspace. + """ + merged = dict(config or {}) + if not connect_config_path: + return merged + + with open(connect_config_path, "r", encoding="utf-8") as f: + overlay = json.load(f) + if not isinstance(overlay, dict): + raise ValueError(f"{connect_config_path} must contain a JSON object") + + foundation_keys = { + "_connectConfigPath", + "activeAgent", + "agent", + "agents", + "connections", + "dataverseEndpoint", + "environmentId", + "selected_products", + "setup", + "status", + } + for key, value in overlay.items(): + if key not in foundation_keys: + merged[key] = value + merged["_connectConfigPath"] = connect_config_path + return merged + + def _run_single_checkpoint(args): """Run exactly one checkpoint (or family) by ID and report only its result. @@ -743,6 +778,12 @@ def _run_single_checkpoint(args): print("ERROR: .local/config.json not found. Run /setup first.") sys.exit(1) + try: + config = _merge_connect_config(config, getattr(args, "connect_config", None)) + except (OSError, ValueError, json.JSONDecodeError) as e: + print(f"ERROR: Unable to load --connect-config: {e}") + sys.exit(1) + env_url = args.environment_url or config.get("dataverseEndpoint", "") if plan.requires_dataverse_endpoint and not env_url: print("ERROR: No dataverseEndpoint in .local/config.json.") @@ -909,6 +950,12 @@ def _run_single_checkpoint(args): target_matcher=lambda cid: registry.matches(target, cid), ) runner.config = config + runner.agent_slug = ( + getattr(args, "agent_slug", None) + or config.get("activeAgent") + or (config.get("agent") or {}).get("slug") + or "" + ) runner.env_url = env_url runner.dv_token = dv_token runner.env_id = env_id @@ -1075,6 +1122,24 @@ def main(): "prerequisites and initialises only the clients it needs. Mutually " "exclusive with --scope.", ) + parser.add_argument( + "--connect-config", + default=None, + help=( + "Merge a provider-specific connect config JSON object into " + ".local/config.json for this run. Connect/setup skills use this " + "when their validation state intentionally lives outside the " + "foundation config." + ), + ) + parser.add_argument( + "--agent-slug", + default=None, + help=( + "Scope agent-local checks to one workspace/agents/ folder. " + "Defaults to activeAgent (or agent.slug) from .local/config.json." + ), + ) parser.add_argument( "--list-checkpoints", action="store_true", help="List the registered setup checkpoint IDs and families (no broad " @@ -1180,6 +1245,12 @@ def main(): with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) + try: + config = _merge_connect_config(config, args.connect_config) + except (OSError, ValueError, json.JSONDecodeError) as e: + print(f"ERROR: Unable to load --connect-config: {e}") + sys.exit(1) + infra_only_scope = args.scope == "infrastructure" da_local_scope = args.scope == "local" env_url = args.environment_url or config.get("dataverseEndpoint", "") @@ -1495,6 +1566,12 @@ def main(): # --- Build runner --- runner = FlightCheckRunner(scope=args.scope) runner.config = config + runner.agent_slug = ( + getattr(args, "agent_slug", None) + or config.get("activeAgent") + or (config.get("agent") or {}).get("slug") + or "" + ) runner.env_url = env_url runner.dv_token = dv_token runner.env_id = env_id diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 7d10ed0e8..05b34e7d9 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -19,19 +19,16 @@ pass it to step1 as PRE_SELECTED_INTEGRATION. Step1 will skip the Read `src/skills/connect/step1.md` and follow it. (Step 1 asks which integration, detects existing state, and dispatches — -ServiceNow to its own step files; Workday to the lightweight already-installed -lifecycle when the extension exists, otherwise to either the CEA setup -orchestrator or the DA Workday connect skill, depending on which kind of ESS -agent is installed.) +ServiceNow to its own step files; Workday first by agent architecture, then +DA to its package/Entra/tenant checklist or CEA to either the lightweight +already-installed lifecycle or the full setup orchestrator.) --- ## Routing Each integration routes differently — ServiceNow has its own step files; -Workday uses the shared lifecycle for an existing extension, or delegates a -new installation to either the CEA setup orchestrator or the DA Workday -connect skill: +Workday routes by architecture before package detection: - **ServiceNow**: `src/skills/connect/servicenow/` - Steps template: `src/skills/connect/servicenow/steps.md` @@ -47,27 +44,29 @@ connect skill: - Step 3 (Basic): `step3-basic.md` — install extension pack (Basic fields) - Step 4: `step4.md` — verify connection -- **Workday**: three paths, chosen automatically by `step1.md`: - - **Extension already installed elsewhere** — a lightweight lifecycle that +- **Workday**: + - **CEA extension already installed elsewhere** — a lightweight lifecycle that confirms the extension and its connections, wires this agent's Workday topics, and validates the connection end to end: `src/skills/connect/workday/SKILL.md`, driven by the generic `src/skills/connect/shared/lifecycle-runner.md` against `src/skills/connect/workday/contract.json`. State: - `.local/connect/workday/lifecycle.json`. This same runner + contract + `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. This same runner + contract pattern is what a future integration reuses — a new ISV only needs its own contract file and action fragments, not a new runner. - - **Nothing installed yet, CEA agent** — the full **setup orchestrator** + - **CEA, nothing installed yet** — the full **setup orchestrator** (`src/skills/setup/SKILL.md`). It sequences the six CEA Workday setup skills (environment, ESS install, Entra app, tenant config, extension pack, topic) using the master checklist as a resume-aware spine. State: `.local/setup/workday/tasks.md` + `setupStatus` in `.local/connect/workday/config.json`. - - **Nothing installed yet, DA agent** — the **DA Workday connect skill** + - **DA agent** — the **DA Workday connect skill** (`src/skills/setup/workday-da/SKILL.md`). It sequences four steps - (extension pack, Entra app, tenant config, verify) the same resume-aware - way. State: `.local/setup/workday-da/tasks.md` + `setupStatus` in - `.local/connect/workday-da/config.json`. + (extension pack, Entra app, tenant config, configuration review) the same + resume-aware way. State: `.local/setup/workday-da/tasks.md` + + `setupStatus` in `.local/connect/workday-da/config.json`. This checklist + does not claim the DA live connection or agent path is ready; those checks + remain deferred until DA-scoped binding and runtime validations exist. `src/skills/connect/step1.md` reads `.local/setup/config.json`'s `selected_products` to choose the correct installation path. diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md index 383fa901f..f0195dd97 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md @@ -11,7 +11,8 @@ agent. The runner itself contains no integration-specific logic — it only reads a contract, runs the checkpoints it names, and renders results. **Canonical contract file:** `src/skills/connect/{provider}/contract.json` -**Canonical state file (per agent):** `.local/connect/{provider}/lifecycle.json` +**Canonical state file (per agent):** +`.local/connect/{provider}/agents/{agentSlug}/lifecycle.json` --- @@ -44,9 +45,11 @@ reads a contract, runs the checkpoints it names, and renders results. | Field | Type | Required | Notes | |-------|------|----------|-------| +| `connectConfig` | string | no | Provider-specific config JSON to pass to FlightCheck as `--connect-config`. Use this when provider state intentionally lives outside `.local/config.json`; the explicit file prevents architecture-specific state from being guessed or merged. | | `id` | string | yes | Stable, internal only — never shown to the user. | | `label` | string | yes | Plain-language description shown in the up-front plan and the resume checklist (e.g. `"Confirm the Workday extension and its connections are healthy"`). No internal IDs, checkpoint names, or file paths. | -| `checkpoints` | array of strings | yes | One or more FlightCheck checkpoint IDs (or family wildcards, e.g. `"WD-FLOW-*"`) that gate this phase. The phase is `done` only when every listed checkpoint reports `Passed`, `Manual` (after attestation), `NotConfigured`, or `Skipped` — see "Phase status rules" below. | +| `checkpoints` | array of strings | yes | One or more FlightCheck checkpoint IDs (or family wildcards, e.g. `"WD-FLOW-*"`) that gate this phase. The phase is `done` only when every listed checkpoint returns a status allowed by `completionStatuses`. | +| `completionStatuses` | array of strings | no | Statuses allowed to complete this phase. Defaults to `["Passed"]`. Add `Manual`, `Warning`, `NotConfigured`, or `Skipped` only when that outcome is genuinely sufficient for this specific phase; acknowledgement alone must not turn missing evidence into a healthy result. | | `mutates` | boolean | no (default `false`) | `true` if completing this phase changes the live agent (edits a file, pushes a change). Drives the role gate below. | | `requiredRole` | string | required when `mutates` is `true` | Human-readable role name passed to `permission-gate.md` as `REQUIRED_ROLE` before the phase's action runs. | | `gateMode` | string | no (default `"attested"`) | `"programmatic"` or `"attested"` — passed to `permission-gate.md` as `GATE_MODE`. Use `"programmatic"` only when `roleQuery` names a real, working query. | @@ -54,6 +57,7 @@ reads a contract, runs the checkpoints it names, and renders results. | `roleQueryPassNames` | array of strings | required when `gateMode` is `"programmatic"` | Role names in the query's result that count as holding `requiredRole` (include the role itself and any role that supersedes it, e.g. `System Administrator`). | | `actionDoc` | string (path) | required when `mutates` is `true` | Path to a provider-owned markdown fragment containing the bespoke steps needed to make the phase's checkpoint(s) pass (e.g. editing a topic file and pushing it). The runner reads and follows this file; it contains its own Message blocks and is written by the provider, not the runner. | | `rollbackLabel` | string | no | Passed to `scripts/checkpoint.py` before a mutating action runs, so the operator has a named restore point. | +| `rollbackPushGlob` | string | no | Required with `rollbackLabel` when the action pushes a local file. The runner restores only this path from the named checkpoint and uses the same exact `push.py --only` glob when publishing the rollback. | ### Evolving a phase's checkpoint list @@ -78,14 +82,15 @@ this schema does not invent a parallel status vocabulary: | Checkpoint status | Phase effect | |---|---| | `Passed` | Counts toward `done`. | -| `Manual` | Counts toward `done` only after the user explicitly attests (same rule as `checklist-updater.md`'s U.2 — a `Manual` result is never auto-completed). | -| `NotConfigured` / `Skipped` | Counts toward `done` — nothing to fix, does not block. | -| `Warning` | Counts toward `done` only after the user is shown the warning and chooses to continue (never silently ignored). | +| `Manual` | Counts toward `done` only when listed in `completionStatuses` and the user explicitly attests. | +| `NotConfigured` / `Skipped` | Counts toward `done` only when explicitly listed in `completionStatuses`. | +| `Warning` | Counts toward `done` only when explicitly listed in `completionStatuses` and the user chooses to continue. | | `Failed` / `Error` | Blocks the phase. The runner shows the remediation and stops advancing; the user retries after fixing it, or exits and resumes later. | -A phase is `done` only when **every** checkpoint it lists resolves to one of -the "counts toward done" outcomes above. Partial results leave the phase -`in-progress`. +A phase is `done` only when **every** checkpoint's current status appears in +that phase's `completionStatuses` (default `Passed`) and any required +acknowledgement is complete. Partial or unavailable evidence leaves the phase +`in-progress`; `Failed`/`Error` blocks it. --- @@ -94,6 +99,7 @@ the "counts toward done" outcomes above. Partial results leave the phase ```json { "provider": "workday", + "agentSlug": "employee-self-service-hr", "attested": true, "attestedAt": "2026-09-15T14:02:00Z", "currentPhase": "agent-wiring", @@ -124,6 +130,10 @@ the "counts toward done" outcomes above. Partial results leave the phase action doc has been followed at least once (so a resume doesn't re-apply an idempotent-unsafe action; it re-verifies instead). +The `agentSlug` and state-file path are mandatory isolation boundaries. A +provider may be connected to multiple agents in one workspace; no agent may +reuse another agent's progress or checkpoint results. + --- ## Round-trip contract diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md index 726596a2d..1486a0fbf 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md @@ -15,10 +15,13 @@ path, or the word "checkpoint"/"contract"/"state file" to the user. **Inputs from the calling file:** - `PROVIDER` — the provider key (e.g. `"workday"`), matching a contract at `src/skills/connect/{PROVIDER}/contract.json`. +- `AGENT_SLUG` — the active agent's slug from `.local/config.json` + (`activeAgent`, falling back to `agent.slug`). **Files:** - **Contract (read-only):** `src/skills/connect/{PROVIDER}/contract.json` -- **State (read/write):** `.local/connect/{PROVIDER}/lifecycle.json` — shape +- **State (read/write):** + `.local/connect/{PROVIDER}/agents/{AGENT_SLUG}/lifecycle.json` — shape defined in `lifecycle-contract-schema.md`. --- @@ -28,10 +31,19 @@ path, or the word "checkpoint"/"contract"/"state file" to the user. Read `src/skills/connect/{PROVIDER}/contract.json`. If it does not exist, **stop and report** — the calling file named a provider with no contract. -Read `.local/connect/{PROVIDER}/lifecycle.json`. If it does not exist, this is -a first run: initialize it in memory with `attested: false` and every phase -from the contract at `status: "pending"`, `checkpointResults: {}` — do not -write it to disk yet (write only after the plan is shown, in L.1). +If the contract has `connectConfig` and that file exists, add +`--connect-config "{connectConfig}"` to every FlightCheck command in this +runner. If it does not exist, omit the argument; do not substitute another +provider or architecture's state. + +If `AGENT_SLUG` is empty, stop and ask the user to run `/setup`; agent-specific +mutation and validation must never fall back to scanning every local agent. + +Read `.local/connect/{PROVIDER}/agents/{AGENT_SLUG}/lifecycle.json`. If it +does not exist, this is a first run: initialize it in memory with +`agentSlug: AGENT_SLUG`, `attested: false`, and every phase from the contract +at `status: "pending"`, `checkpointResults: {}` — do not write it to disk yet +(write only after the plan is shown, in L.1). --- @@ -77,9 +89,10 @@ Use the `vscode_askQuestions` tool: **If "Not now":** Stop here. Do not write the state file — the next invocation should show this same plan again. -**If "Yes, let's go":** Write `.local/connect/{PROVIDER}/lifecycle.json` now -with `attested: true`, `attestedAt` = current UTC timestamp, and every phase -at `status: "pending"`. Continue to L.2. +**If "Yes, let's go":** Write +`.local/connect/{PROVIDER}/agents/{AGENT_SLUG}/lifecycle.json` now with +`agentSlug: AGENT_SLUG`, `attested: true`, `attestedAt` = current UTC +timestamp, and every phase at `status: "pending"`. Continue to L.2. --- @@ -99,8 +112,8 @@ anything else: 1. Re-run every checkpoint the phase lists (see L.4's checkpoint-running steps — reuse that exact mechanism here, silently, without re-showing the up-front plan). -2. If every checkpoint still resolves to a "counts toward done" outcome (per - `lifecycle-contract-schema.md`'s status table), update `lastVerifiedAt` and +2. If every checkpoint still resolves to a status allowed by that phase's + `completionStatuses`, update `lastVerifiedAt` and leave the phase `done`. Do **not** re-render the U.0 table for a phase that was already `done` and stays `done` on resume — only surface output for phases that change state or that are not yet done. @@ -164,12 +177,18 @@ For each checkpoint ID the current phase lists, run: python scripts/flightcheck/cli.py --checkpoint {ID} ``` +If the contract has `connectConfig` and that file exists, add +`--connect-config "{connectConfig}"`. For agent-local checkpoints, also add +`--agent-slug "{AGENT_SLUG}"`. Use the same arguments when re-verifying +completed phases in L.2. + After each run, render the result using the exact U.0 and U.0a routines from `src/skills/setup/shared/checklist-updater.md` (read that file's U.0/U.0a sections and apply them verbatim against `workspace/flightcheck/results.json` — do not re-implement or paraphrase that rendering logic here). -Aggregate the phase's outcome per the status table in +Aggregate the phase's outcome using the phase's `completionStatuses` +(default `["Passed"]`) and the status rules in `lifecycle-contract-schema.md`: - **All checkpoints "count toward done"** (after any needed attestation for @@ -181,6 +200,22 @@ Aggregate the phase's outcome per the status table in - **Any checkpoint `Failed`/`Error`:** set `phases.{id}.status = "blocked"`, record `checkpointResults`. Write the state file. + If this phase has `actionApplied: true`, `rollbackLabel`, and + `rollbackPushGlob`, restore and publish the exact pre-action state: + + ``` + python scripts/checkpoint.py --revert-reason "{rollbackLabel}" --only "{rollbackPushGlob}" + python scripts/push.py --only "{rollbackPushGlob}" --dry-run + python scripts/push.py --only "{rollbackPushGlob}" --yes + ``` + + If all three commands succeed, set `actionApplied = false`, keep the phase + `in-progress`, record `rolledBackAt` = now, and write the state file. This + lets a later invocation re-run the gated action instead of skipping an + action that was undone. If any rollback command fails, leave + `actionApplied = true`, keep the phase `blocked`, and report that both the + phase and rollback need manual attention. + **Message:** I can't complete this step yet — {plain-language summary of what's @@ -191,6 +226,10 @@ Aggregate the phase's outcome per the status table in Stop. Do not attempt later phases. +- **Any status not in `completionStatuses`:** keep the phase + `in-progress`, record the result, show its remediation, and stop. Do not + describe the provider as connected. + --- ## L.5 — Completion @@ -199,7 +238,8 @@ Once every phase is `done`: **Message:** -{displayName} is connected. Everything I checked is healthy. +{displayName} is connected to this agent. Every required validation phase in +the provider plan passed. **End message.** diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index 84f96661e..f8d0c35d8 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -7,20 +7,25 @@ Do not rephrase, add commentary, or tell the user what tools you are calling. ## 1.1 — Check what's already connected +Read `.local/config.json` when it exists and resolve `ACTIVE_AGENT_SLUG` from +`activeAgent`, falling back to `agent.slug`. Use this identity for every +agent-specific Workday state and validation below. + Build a list of connected integrations (if any): - **ServiceNow** — connected if `.local/connect/servicenow/steps.md` exists and all items are checked. -- **Workday** — connected if any of these are true: +- **Workday** — connected if either: - `.local/connect/workday/config.json` exists and its `setupStatus` shows every CEA setup row (`S1.1` … `S6.2`) in state `done` (the CEA setup - orchestrator owns this state), - - `.local/connect/workday-da/config.json` exists and its `setupStatus` - shows every DA row (`DA1.1` … `DA4.1`) in state `done` (the DA Workday - connect skill owns this state), or - - `.local/connect/workday/lifecycle.json` exists and every phase is - `done` (this agent was wired to an extension already installed - elsewhere — see 1.3 below). + orchestrator owns this state), or + - `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` exists + and every phase is `done` (this specific CEA agent was wired to an + extension already installed elsewhere — see 1.3 below). + +The DA checklist deliberately does not count as "connected" yet. It confirms +package, Entra, and tenant configuration, but DA-scoped live connection +binding and agent-path validation are still deferred. --- @@ -213,14 +218,8 @@ Now read `src/skills/connect/servicenow/step1.md` and follow it. ### If the user chose Workday (2 or "workday") -**If `.local/connect/workday/lifecycle.json` already exists** (this agent was -previously wired to an already-installed extension via 1.3's second path -below): read `src/skills/connect/workday/SKILL.md` and follow it. It -re-verifies everything live before reporting "connected" — it never trusts -the saved state on its own. - -**Otherwise**, first find out which kind of ESS agent is in this environment. -Read `.local/setup/config.json` and look at +First find out which kind of ESS agent is in this environment. Read +`.local/setup/config.json` and look at `selected_products` (each entry is one of `da.esshr` / `da.essit` / `da.esshub` / `cea.esshr` / `cea.essit` / `cea.esshub`): @@ -236,9 +235,27 @@ Read `.local/setup/config.json` and look at Stop here. -**Once an ESS agent is confirmed**, check whether a Workday extension already -exists **anywhere in this environment**, independent of whether this -particular agent has been set up against it yet: +### DA agent + +**If any `selected_products` entry starts with `da.`**, use only the DA path. +Do not run `WD-PKG-001` or the CEA lifecycle: DA packages share some Workday +connection-reference names with CEA, so that checkpoint is not an +architecture discriminator. + +Read `src/skills/setup/workday-da/SKILL.md` and follow it. That skill runs +`WD-DA-PKG-001`, installs any missing DA Workday child package, and resumes +the DA Entra and tenant checklist from live state. + +### CEA agent + +**Otherwise, only `cea.*` entries are present.** + +If `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` already +exists, read `src/skills/connect/workday/SKILL.md` and follow it. It +re-verifies the active agent live before reporting "connected." + +Otherwise, check whether a CEA Workday extension already exists in this +environment: ``` python scripts/flightcheck/cli.py --checkpoint WD-PKG-001 @@ -250,18 +267,10 @@ install. Read `src/skills/connect/workday/SKILL.md` and follow it; it shows the user what it will check before doing anything, and only makes changes once they confirm. -**If anything other than `Passed`**, route by the installed agent architecture: - -- **Any `selected_products` entry starts with `da.`** — read - `src/skills/setup/workday-da/SKILL.md` and follow it. This path installs the - Workday extension package, provisions the Workday Entra app, configures the - Workday tenant, and verifies the connection. It is resume-aware. - -- **Otherwise, only `cea.*` entries are present** — read - `src/skills/setup/SKILL.md` and follow it. This path provisions the Power - Platform environment, installs the ESS base agent, provisions the Entra app, - configures the Workday tenant, installs the extension pack, and verifies the - connection. It is resume-aware. +**If anything other than `Passed`**, read `src/skills/setup/SKILL.md` and +follow it. This path provisions the Power Platform environment, installs the +ESS base agent, provisions the Entra app, configures the Workday tenant, +installs the extension pack, and verifies the connection. It is resume-aware. ### If the user said something else diff --git a/solutions/ess-maker-skills/src/skills/connect/steps.md b/solutions/ess-maker-skills/src/skills/connect/steps.md index fddb22492..bb235331e 100644 --- a/solutions/ess-maker-skills/src/skills/connect/steps.md +++ b/solutions/ess-maker-skills/src/skills/connect/steps.md @@ -4,8 +4,8 @@ This folder does not use a top-level steps.md. Each integration has its own steps.md and config.json under its subfolder: - `servicenow/steps.md` + `servicenow/config.json` -- Workday has no `connect/workday/` subfolder — it's handled by - `src/skills/setup/SKILL.md` (CEA) or `src/skills/setup/workday-da/SKILL.md` - (DA), selected by `step1.md` based on which kind of ESS agent is installed. +- Workday routes by architecture: CEA uses `connect/workday/` when an + extension already exists or `src/skills/setup/SKILL.md` for full setup; + DA uses `src/skills/setup/workday-da/SKILL.md`. See SKILL.md for routing logic. 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 d2ac8675b..42801740e 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/SKILL.md @@ -14,10 +14,12 @@ Do not rephrase, add commentary, or tell the user what tools you are calling. ## W.1 — Run the lifecycle Read `src/skills/connect/shared/lifecycle-runner.md` and follow it with -`PROVIDER = "workday"`. +`PROVIDER = "workday"` and `AGENT_SLUG` resolved from `.local/config.json` +(`activeAgent`, falling back to `agent.slug`). That file loads `src/skills/connect/workday/contract.json`, resumes any -in-progress state from `.local/connect/workday/lifecycle.json`, shows the +in-progress state from +`.local/connect/workday/agents/{AGENT_SLUG}/lifecycle.json`, shows the plan and collects attestation on a first run, live-re-verifies anything already recorded done, then runs whichever phase is next — confirming the extension and its connections, wiring the agent's Workday topics, and diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md index 81a48e9e5..069fafeb2 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md +++ b/solutions/ess-maker-skills/src/skills/connect/workday/actions/wire-user-context-redirect.md @@ -33,7 +33,7 @@ available yet." ## A.2 — Resolve the installed system topic Find the installed Workday "Set User Context" system topic's dialog id under -`.local/agents/{slug}/topics/`. Use the actual installed topic name — do not +`workspace/agents/{AGENT_SLUG}/topics/`. Use the actual installed topic name — do not assume a fixed name, since it varies by install path (for example, `WorkdaySystemGetUserContextV2` on the current extension pack). @@ -41,7 +41,8 @@ assume a fixed name, since it varies by install path (for example, ## A.3 — Edit the redirect -Set the agent's `workspace/agents/{slug}/topics/user-context-setup.mcs.yml` +Set the agent's +`workspace/agents/{AGENT_SLUG}/topics/user-context-setup.mcs.yml` `OnRedirect` to a `BeginDialog` calling that dialog id: ```yaml @@ -67,23 +68,42 @@ before continuing. Preview and push: ``` -python scripts/push.py --dry-run +python scripts/push.py --only "topics/user-context-setup.mcs.yml" --dry-run ``` -Review the preview. If it only shows the `user-context-setup` topic changing, -continue: +Review the preview. The preview must contain only the `user-context-setup` topic. If any other +file appears, stop and report it instead of publishing unrelated work. + +Use the `vscode_askQuestions` tool: + +```json +[ + { + "header": "Publish Workday wiring", + "question": "Publish this scoped User Context topic change to the active agent?", + "options": [ + { "label": "Publish", "recommended": true }, + { "label": "Not now" } + ], + "allowFreeformInput": false + } +] +``` + +If the user selects **Not now**, stop without pushing and leave the phase +`in-progress`. If the user selects **Publish**, run: ``` -python scripts/push.py --yes +python scripts/push.py --only "topics/user-context-setup.mcs.yml" --yes ``` -If the dry run shows anything unexpected changing, stop and report it instead -of pushing — do not push a change wider than this one topic edit. +The explicit question above is the approval for this concrete scoped change; +`--yes` prevents the script from attempting a second terminal-only prompt. --- ## A.5 — Return -Return to the lifecycle runner. It re-runs `WD-REST-002` immediately after -this file completes and decides whether to advance or roll back — this file -does not re-run the checkpoint itself. +Return to the lifecycle runner. It re-runs `WD-REST-002` for `AGENT_SLUG` +immediately after this file completes and decides whether to advance or use +the named restore point — this file does not re-run the checkpoint itself. diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json index 555b31539..a3c60a007 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json +++ b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json @@ -1,6 +1,7 @@ { "provider": "workday", "displayName": "Workday", + "connectConfig": ".local/connect/workday/config.json", "detect": { "checkpoint": "WD-PKG-001", "meansInstalled": "Passed" @@ -10,12 +11,14 @@ "id": "discovery", "label": "Confirm the Workday extension is installed and its connections are healthy", "checkpoints": ["WD-PKG-001", "DV-CONN-001", "WD-CONN-012"], + "completionStatuses": ["Passed"], "mutates": false }, { "id": "agent-wiring", "label": "Wire this agent's Workday topics so they can run", "checkpoints": ["WD-REST-002"], + "completionStatuses": ["Passed"], "mutates": true, "requiredRole": "Environment Maker", "gateMode": "programmatic", @@ -25,12 +28,14 @@ ], "roleQueryPassNames": ["Environment Maker", "System Customizer", "System Administrator"], "actionDoc": "src/skills/connect/workday/actions/wire-user-context-redirect.md", - "rollbackLabel": "Add User Context redirect to Workday" + "rollbackLabel": "Add User Context redirect to Workday", + "rollbackPushGlob": "topics/user-context-setup.mcs.yml" }, { "id": "validation", "label": "Confirm the end-to-end connection is healthy", "checkpoints": ["WD-RUN-001"], + "completionStatuses": ["Passed"], "mutates": false } ] 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 826735ca1..d84b2059c 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 @@ -84,8 +84,8 @@ ID, App ID URI) are safe to capture in chat — see - {m} Activate the Workday authentication policy - {m} Match the signing certificate - **4. Verify your Workday connection** - - {m} Review your Workday connection + **4. Review your Workday configuration** + - {m} Review your Workday configuration Picking up at: {title of the first item whose state is not `done`}. @@ -157,7 +157,7 @@ incomplete row. When it returns, go back to **Start** to resume at the next unverified row. -### DA4.1 — Verify your Workday connection (DA-4) +### DA4.1 — Review your Workday configuration (DA-4) Read `src/skills/setup/workday-da/verify-connection.md` and follow it. That playbook re-runs `WD-DA-PKG-001` to reconfirm the extension package, summarizes @@ -166,8 +166,8 @@ the Entra and tenant configuration recorded in does — and does not yet — verify about the live connection (see the "Deferred to a follow-up" note in `tasks.md`), pointing to a full FlightCheck run for anything beyond that. It updates row **DA4.1** through the shared -checklist-updater (`advisory` gate — completes once shown, regardless of -findings). +checklist-updater (`prog` gate — completes only when the live package recheck +passes). When it returns, go back to **Start** — every row should now be `done`. @@ -175,7 +175,8 @@ When it returns, go back to **Start** — every row should now be `done`. **Message:** -Your Workday connection checklist is complete. Type `/menu` to see what you can -do next. +Your Workday installation and configuration checklist is complete. The final +review explains the remaining live connection and agent-path checks. Type +`/menu` to see what you can do next. **End message.** 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 ec3734a8f..c3450e7f1 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 @@ -206,7 +206,7 @@ in Entra to make sure they match. **Verify (WD-CONN-102):** ``` -python scripts/flightcheck/cli.py --checkpoint 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 @@ -322,7 +322,7 @@ Now I'll confirm the Workday API client you registered was captured correctly. **Verify (WD-API-CLIENT-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-API-CLIENT-001 +python scripts/flightcheck/cli.py --checkpoint WD-API-CLIENT-001 --connect-config ".local/connect/workday-da/config.json" ``` This echoes the captured `oauthClientId` / `tokenEndpoint` and restates the @@ -372,7 +372,7 @@ are in place. **Verify (WD-TENANT-001):** ``` -python scripts/flightcheck/cli.py --checkpoint 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 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 aa46a8696..06c1de04c 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 @@ -22,8 +22,20 @@ exists, and whether Workday is already installed against it: python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 ``` -- **`PASSED`** → the Workday extension package is already installed. Show the - result, record `vertical` (`hr`/`it`, from the check's finding) into +Read the checkpoint result from `workspace/flightcheck/results.json`. Build +`TARGET_VERTICALS` from the live `Detected DA base agent editions` value: + +- `HR` → `hr` +- `IT` → `it` + +An environment may contain both HR and IT. Treat each target vertical +independently; DA1.1 completes only when every target has its corresponding +Workday child package. Do not derive this list from `selected_products`; that +records setup intent and can differ from the packages currently installed in +the environment. + +- **`PASSED`** → every required Workday extension package is already installed. + Show the result, record `verticals` (the `TARGET_VERTICALS` array) into `.local/connect/workday-da/config.json`, and go to **record DA1.1** below. - **`FAILED`** with "No DA Employee Self-Service base agent … was found" → your DA base agent isn't installed yet. Stop here — this skill doesn't @@ -51,19 +63,18 @@ python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 ## P1.1 — Attempt an automated install ``` -python scripts/install_workday_da_extension.py --url "{ENVIRONMENT_URL}" --vertical "{hr|it}" +python scripts/install_workday_da_extension.py --url "{ENVIRONMENT_URL}" --vertical "{vertical}" ``` -Use the `vertical` the check just reported (whichever DA base agent edition — -HR or IT — is installed). `ENVIRONMENT_URL` is the Dataverse endpoint from -`.local/config.json`. +Run the command once for each target vertical that the checkpoint reported +missing. `ENVIRONMENT_URL` is the Dataverse endpoint from `.local/config.json`. Parse the script's JSON marker line: -- **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → the extension installed (or was - already installed and the script confirmed it). Re-run - `--checkpoint WD-DA-PKG-001` to confirm it now reports `PASSED`, then go to - **record DA1.1**. +- **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → that vertical installed (or was + already installed and the script confirmed it). Continue with the next + missing vertical. After all installs, re-run `--checkpoint WD-DA-PKG-001`; + proceed only when it reports `PASSED`. - **`WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:`** → this tenant's Marketplace catalog doesn't list the Workday extension package for the DA agent, so it can't be installed automatically here. This is expected in some tenants — @@ -97,9 +108,9 @@ installed the base Employee Self-Service agent: 1. Open the [Microsoft 365 admin center](https://admin.microsoft.com) (or AppSource, if that's where you installed the base agent). -2. Find the **Workday extension for Employee Self-Service** package for the - **{edition}** edition of your agent. -3. Deploy it to this environment. +2. Find the **Workday extension for Employee Self-Service** package for each + missing edition: **{missing editions}**. +3. Deploy each missing package to this environment. 4. Wait for the deployment to finish, then tell me it's done and I'll verify it. @@ -114,15 +125,17 @@ until it passes. While it is not yet passing, keep DA1.1 `in-progress`. When `WD-DA-PKG-001` is `PASSED`: -1. Merge `vertical` (`hr`/`it`) into `.local/connect/workday-da/config.json` - (round-trip merge — never drop other keys). +1. Merge `verticals` (array containing every installed target: `hr`, `it`, or + both) into `.local/connect/workday-da/config.json` (round-trip merge — + never drop other keys). For backward compatibility, also write `vertical` + only when exactly one target is present; remove a stale singular + `vertical` when both are present. 2. Call [`shared/checklist-updater.md`](./shared/checklist-updater.md) with `STEP_ID = "DA1.1"`, `CHECKPOINT_RESULT = "PASSED"`, `GATE = "prog"`. **Message:** -The Workday extension package is installed for the **{edition}** edition of -your agent. +The Workday extension package is installed for **{installed editions}**. **End message.** 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 2bc7c3cb4..c43822eda 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 @@ -308,7 +308,7 @@ I'll continue automatically once it finishes. **End message.** ``` -python scripts/flightcheck/cli.py --checkpoint 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 signing-certificate health. It returns @@ -382,7 +382,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 +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SCOPE-001 --connect-config ".local/connect/workday-da/config.json" ``` - **`PASSED`** → update **DA2.2** via @@ -429,7 +429,7 @@ permissions. **Verify (WD-ENTRA-CONSENT-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-CONSENT-001 +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-CONSENT-001 --connect-config ".local/connect/workday-da/config.json" ``` - **`PASSED`** → update **DA2.3** via @@ -469,7 +469,7 @@ so, that the right users are assigned. **Verify (WD-ASSIGN-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-ASSIGN-001 +python scripts/flightcheck/cli.py --checkpoint WD-ASSIGN-001 --connect-config ".local/connect/workday-da/config.json" ``` - **`PASSED`** (assignment satisfied via a group, or not required) → update @@ -534,7 +534,7 @@ your Workday tenant expects. **Verify (WD-ENTRA-NAMEID-001):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-NAMEID-001 +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-NAMEID-001 --connect-config ".local/connect/workday-da/config.json" ``` - **`PASSED`** (a NameID-overriding policy is assigned) → update **DA2.5** via @@ -571,7 +571,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 +python scripts/flightcheck/cli.py --checkpoint WD-ENTRA-SIGNOPT-001 --connect-config ".local/connect/workday-da/config.json" ``` Present the checkpoint's instructions — its remediation now names the customer's @@ -617,7 +617,7 @@ linked to the Workday tenant your agent uses. **Verify (WD-CONN-010):** ``` -python scripts/flightcheck/cli.py --checkpoint WD-CONN-010 +python scripts/flightcheck/cli.py --checkpoint WD-CONN-010 --connect-config ".local/connect/workday-da/config.json" ``` `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 8cefb4e53..62e739e90 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 @@ -179,7 +179,7 @@ Decide `Status` as follows: |--------|-----------|--------------------| | `prog` | `CHECKPOINT_RESULT` = `PASSED` | `done` | | `prog` | `CHECKPOINT_RESULT` = `FAILED` | `blocked` | -| `prog` | `CHECKPOINT_RESULT` = `WARNING` / `null` | `in-progress` | +| `prog` | `CHECKPOINT_RESULT` = `WARNING` / `SKIPPED` / `null` | `in-progress` | | `manual` / `attest` | `ACK` = `true` (user acknowledged **and** evidence captured) | `done` | | `manual` / `attest` | `ACK` = `false`, regardless of `CHECKPOINT_RESULT` | `in-progress` (or `blocked` if `FAILED`) | | `advisory` | the advisory step has been run and its report shown (or attempted and skipped) | `done` | 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 ede22866c..9f009bb8b 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 @@ -49,8 +49,9 @@ read by later steps. Unknown/absent fields are treated as `null`. | `domainName` | string | DA-3 | Workday domain name, when discovered. | | `tenantId` | string | DA-2 | **Entra** tenant ID (GUID) — set during Entra setup. | | `installPath` | string | DA-3 | `"simplified"`. | -| `status` | string | all | `"in-progress"` \| `"connected"`. | -| `vertical` | string | DA-1 | `"hr"` \| `"it"` — which DA base agent edition the Workday extension was installed against (from `WD-DA-PKG-001`). | +| `status` | string | all | `"in-progress"` \| `"configured"`. `"configured"` means the package, Entra, and Workday tenant checklist is complete; it does not claim live DA connection readiness. | +| `verticals` | array[string] | DA-1 | Every DA base-agent edition with its required Workday child installed: `["hr"]`, `["it"]`, or `["hr", "it"]`. | +| `vertical` | string | DA-1 | Backward-compatible singular alias, written only when `verticals` has exactly one item. | ### Entra app + OAuth client (owned by DA-2 / DA-3) @@ -122,6 +123,7 @@ verification beyond installation: "soapBaseUrl": "https://wd2-impl-services1.workday.com/ccx/service", "tenantId": "00000000-0000-0000-0000-000000000000", "installPath": "simplified", + "verticals": ["hr"], "vertical": "hr", "entraSSO": true, "entraAppId": "11111111-1111-1111-1111-111111111111", 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 47a4405c3..bd3de9d1e 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 @@ -81,10 +81,10 @@ express; all items start `pending`. - [ ] **Match the signing certificate** — Confirm the Workday-side signing certificate matches the one in Entra (validity dates, or an externally-computed SHA-1 — Workday shows no thumbprint). -### 4. Verify your Workday connection +### 4. Review your Workday configuration -- [ ] **Review your Workday connection** — Confirm the extension package, single sign-on, and tenant configuration are all in place, and see what's left before your agent can use Workday. - +- [ ] **Review your Workday configuration** — Confirm the extension package, single sign-on, and tenant configuration are all in place, and see which live connection checks remain before your agent can use Workday. + > An item backed by an **attest** or **manual** gate is **never** auto-completed > by its checkpoint — it requires an explicit user acknowledgement plus 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 9b934334e..f53844120 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 @@ -1,11 +1,11 @@ -# DA-4 — Verify Your Workday Connection +# DA-4 — Review Your Workday Configuration Role: **Environment Maker**. This step reviews everything the earlier steps verified, re-confirms the extension package is still installed, and gives an honest summary of what this skill does — and does not yet — check about the -live Workday connection. It owns master-checklist row **DA4.1** (an `advisory` -row: it completes once shown, regardless of what it finds). +live Workday connection. It owns master-checklist row **DA4.1** (a +programmatic row that completes only after the package recheck passes). 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 @@ -13,7 +13,7 @@ files you are reading. --- -## DA4.1 — Review your Workday connection +## DA4.1 — Review your Workday configuration **Re-confirm the extension package.** @@ -24,6 +24,17 @@ python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 Show the result per [`shared/checklist-updater.md`](shared/checklist-updater.md) §U.0. +If the current result is not `PASSED`, do not continue from the persisted +DA1.1 state: + +- `FAILED` → update DA1.1 with `GATE="prog"`, + `CHECKPOINT_RESULT="FAILED"` so it becomes `blocked`. +- `WARNING` / `SKIPPED` → update DA1.1 with `GATE="prog"` and that result so + it becomes `in-progress`. + +Tell the user the package must be restored or reverified, then return to the +orchestrator. Do not complete DA4.1. + **Summarize the Entra and tenant configuration recorded so far.** Read `.local/connect/workday-da/config.json` and render what's known: @@ -64,19 +75,20 @@ agent can reach Workday: under **Connections**. 2. Try a Workday scenario with your agent (for example, checking a vacation balance) and confirm it returns real data. -3. If it doesn't, run `python scripts/flightcheck/cli.py --scope workdayda` for - the full report, or reach out to your Workday administrator to recheck the - tenant configuration above. +3. If it doesn't, run + `python scripts/flightcheck/cli.py --scope workdayda --connect-config ".local/connect/workday-da/config.json"` + for the DA Workday report, or reach out to your Workday administrator to + recheck the tenant configuration above. **End message.** Update **DA4.1** via [`shared/checklist-updater.md`](shared/checklist-updater.md) -with `STEP_ID="DA4.1"`, `GATE="advisory"`, `CHECKPOINT_RESULT` = the -`WD-DA-PKG-001` result — an advisory row completes once shown, regardless of -findings. +with `STEP_ID="DA4.1"`, `GATE="prog"`, +`CHECKPOINT_RESULT="PASSED"`. --- ## Done -Return control to the orchestrator (`SKILL.md`) — every row should now be `done`. +Return control to the orchestrator (`SKILL.md`) — every configuration row +should now be `done`. diff --git a/tests/flightcheck/checks/test_entra_app.py b/tests/flightcheck/checks/test_entra_app.py index 7b9611b51..e73d4342e 100644 --- a/tests/flightcheck/checks/test_entra_app.py +++ b/tests/flightcheck/checks/test_entra_app.py @@ -390,6 +390,21 @@ def test_partial_runner_config_completed_from_connect( # entraAppId from runner.config; entraAppObjectId filled from connect. assert _workday_hints({"entraAppId": "app-r"}) == ("app-r", "obj-x") + def test_explicit_overlay_does_not_fall_back_to_cea_config( + self, tmp_path, monkeypatch + ) -> None: + from flightcheck.checks.entra_app import _workday_hints + + self._write_connect_config( + tmp_path, {"entraAppId": "cea-app", "entraAppObjectId": "cea-obj"} + ) + monkeypatch.chdir(tmp_path) + + assert _workday_hints({ + "_connectConfigPath": ".local/connect/workday-da/config.json", + "entraAppId": "da-app", + }) == ("da-app", "") + def test_missing_connect_config_returns_empty( self, tmp_path, monkeypatch ) -> None: diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py index 604aad15c..bfd50535f 100644 --- a/tests/flightcheck/checks/test_workday_da.py +++ b/tests/flightcheck/checks/test_workday_da.py @@ -184,6 +184,7 @@ def test_passed_when_it_workday_child_present(runner: _MinimalRunner) -> None: r = _check_workday_da_package_installed(runner)[0] assert r.status == "Passed" assert "msdyn_essdaitworkday" in r.result + assert "Detected DA base agent editions: IT" in r.result @responses.activate @@ -202,6 +203,7 @@ def test_failed_lists_already_installed_vertical_when_other_missing( assert "IT" in r.result assert "Already installed" in r.result assert "msdyn_essdahrworkday" in r.result + assert "Detected DA base agent editions: HR, IT" in r.result @responses.activate diff --git a/tests/flightcheck/checks/test_workday_extension.py b/tests/flightcheck/checks/test_workday_extension.py index 0e641716b..096c257a7 100644 --- a/tests/flightcheck/checks/test_workday_extension.py +++ b/tests/flightcheck/checks/test_workday_extension.py @@ -69,6 +69,7 @@ class _Runner: dv_token: str | None = None pp_admin: Any = None env_id: str | None = None + agent_slug: str = "" _workday_connection_refs: list[dict[str, Any]] = field(default_factory=list) @@ -519,6 +520,42 @@ def test_one_of_two_agents_unwired_fails(self, tmp_path, monkeypatch): assert "broken" in r.result assert "wired" not in r.result.split("missing for:")[1] + def test_active_agent_scope_ignores_unrelated_unwired_agent( + self, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "active", + " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + ) + _write_topic(tmp_path, "unrelated", "kind: AdaptiveDialog\n") + + runner = _Runner(config={}, agent_slug="active") + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.PASSED.value + assert "active" in r.result + assert "unrelated" not in r.result + + def test_active_agent_scope_does_not_pass_from_other_agent( + self, tmp_path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + _write_topic( + tmp_path, + "other", + " - kind: BeginDialog\n dialog: WorkdaySystemGetUserContextV2\n", + ) + _write_topic(tmp_path, "active", "kind: AdaptiveDialog\n") + + runner = _Runner(config={}, agent_slug="active") + r = _by_id(wx.run_workday_extension_checks(runner))["WD-REST-002"] + + assert r.status == Status.FAILED.value + assert "active" in r.result + assert "other" not in r.result + # ───────────────────────────────────────────────────────────────────── # WD-NET-001 — firewall allowlisting (S5.8, always MANUAL attestation). diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index 95f032393..8f5741624 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -39,6 +39,8 @@ def _args( tmp_path: Path, environment_url: str | None = None, environment_id: str | None = None, + connect_config: str | None = None, + agent_slug: str | None = None, no_telemetry: bool = True, invocation_source: str | None = None, quiet_auth: bool = False, @@ -47,6 +49,8 @@ def _args( checkpoint=checkpoint, environment_url=environment_url, environment_id=environment_id, + connect_config=connect_config, + agent_slug=agent_slug, output=str(tmp_path / "out"), no_telemetry=no_telemetry, invocation_source=invocation_source, @@ -75,6 +79,56 @@ def _silence_output(monkeypatch: pytest.MonkeyPatch) -> None: class TestGates: + def test_connect_config_overlay_preserves_foundation_identity( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"entraAppId":"app-123","tenant":"acme","status":"in-progress"}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + { + "dataverseEndpoint": "https://example.crm.dynamics.com", + "agent": {"slug": "ess-hr"}, + "status": "complete", + }, + str(overlay), + ) + + assert merged["dataverseEndpoint"] == "https://example.crm.dynamics.com" + assert merged["agent"] == {"slug": "ess-hr"} + assert merged["entraAppId"] == "app-123" + assert merged["tenant"] == "acme" + assert merged["status"] == "complete" + assert merged["_connectConfigPath"] == str(overlay) + + def test_connect_config_overlay_preserves_foundation_connections( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"connections":{"Workday":{"tenant":"wrong"}}}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + {"connections": {"Workday": {"tenant": "foundation"}}}, + str(overlay), + ) + + assert merged["connections"]["Workday"]["tenant"] == "foundation" + + def test_connect_config_overlay_rejects_non_object( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "invalid.json" + overlay.write_text('["not", "an", "object"]', encoding="utf-8") + + with pytest.raises(ValueError, match="must contain a JSON object"): + cli._merge_connect_config({}, str(overlay)) + def test_environment_checkpoints_accept_explicit_foundation_context( self, ) -> None: @@ -254,6 +308,55 @@ def test_passed_row_exits_0( cli._run_single_checkpoint(_args("FAKE-001", tmp_path)) assert exc.value.code == 0 + def test_assigns_explicit_agent_slug_and_connect_config( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + _silence_output: None, + ) -> None: + overlay = tmp_path / "provider.json" + overlay.write_text('{"tenant":"acme"}', encoding="utf-8") + captured = {} + + class _Spec: + category_label = "Fake" + is_family = False + + class _Plan: + clients = frozenset() + requires_config = False + requires_dataverse_endpoint = False + + def __init__(self) -> None: + self.ordered_fns = [("Fake", self._fn)] + + @staticmethod + def _fn(runner): + captured["agent_slug"] = runner.agent_slug + captured["config"] = runner.config + return [_row("FAKE-001", Status.PASSED.value)] + + monkeypatch.setattr(registry, "resolve", lambda target: _Spec()) + monkeypatch.setattr( + registry, "transitive_requirements", lambda target: _Plan() + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exc: + cli._run_single_checkpoint( + _args( + "FAKE-001", + tmp_path, + connect_config=str(overlay), + agent_slug="active-agent", + ) + ) + + assert exc.value.code == 0 + assert captured["agent_slug"] == "active-agent" + assert captured["config"]["tenant"] == "acme" + assert captured["config"]["_connectConfigPath"] == str(overlay) + def test_failed_row_exits_1( self, tmp_path: Path, diff --git a/tests/scripts/test_checkpoint.py b/tests/scripts/test_checkpoint.py new file mode 100644 index 000000000..0ef55176a --- /dev/null +++ b/tests/scripts/test_checkpoint.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json + +import pytest + +import checkpoint + + +def _write_agent_file(agent_dir, value: str) -> None: + agent_dir.mkdir(parents=True, exist_ok=True) + (agent_dir / "topic.yml").write_text(value, encoding="utf-8") + + +def test_revert_reason_restores_named_checkpoint(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "before") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + _write_agent_file(agent_dir, "edited") + checkpoint.create_checkpoint(str(agent_dir), "auto-save before push") + _write_agent_file(agent_dir, "pushed") + + checkpoint.cmd_revert_reason(str(agent_dir), "before Workday redirect") + + assert (agent_dir / "topic.yml").read_text(encoding="utf-8") == "before" + checkpoints_dir = agent_dir / ".checkpoints" + reasons = [] + for meta_path in checkpoints_dir.glob("*/_meta.json"): + reasons.append(json.loads(meta_path.read_text(encoding="utf-8"))["reason"]) + assert "auto-save before named revert" in reasons + + +def test_revert_reason_fails_when_checkpoint_is_missing(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "current") + + with pytest.raises(SystemExit) as exc: + checkpoint.cmd_revert_reason(str(agent_dir), "missing") + + assert exc.value.code == 1 + + +def test_revert_reason_only_restores_matching_path(tmp_path) -> None: + agent_dir = tmp_path / "agent" + topic_dir = agent_dir / "topics" + topic_dir.mkdir(parents=True) + target = topic_dir / "user-context-setup.mcs.yml" + unrelated = topic_dir / "other.mcs.yml" + target.write_text("before", encoding="utf-8") + unrelated.write_text("before-unrelated", encoding="utf-8") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + target.write_text("edited", encoding="utf-8") + unrelated.write_text("keep-this", encoding="utf-8") + + checkpoint.cmd_revert_reason( + str(agent_dir), + "before Workday redirect", + only="topics/user-context-setup.mcs.yml", + ) + + assert target.read_text(encoding="utf-8") == "before" + assert unrelated.read_text(encoding="utf-8") == "keep-this" + + +def test_revert_reason_only_rejects_parent_traversal(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "before") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + with pytest.raises(ValueError, match="escapes checkpoint"): + checkpoint.cmd_revert_reason( + str(agent_dir), + "before Workday redirect", + only="../outside.yml", + ) + + +def test_revert_reason_only_fails_when_pattern_matches_nothing(tmp_path) -> None: + agent_dir = tmp_path / "agent" + _write_agent_file(agent_dir, "before") + checkpoint.create_checkpoint(str(agent_dir), "before Workday redirect") + + with pytest.raises(ValueError, match="matched no paths"): + checkpoint.cmd_revert_reason( + str(agent_dir), + "before Workday redirect", + only="topics/missing.yml", + ) diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 62b4d67e6..062e513a7 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -1,66 +1,271 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Structural guards for the hybrid Workday setup boundary.""" +"""Structural consistency guard for the Workday setup orchestrator (router) and +the ``/connect workday`` entry point that reaches it. + +Pure-logic, no network (see ``tests/AGENTS.md`` — pure-logic helpers are exempt +from the cassette rule). The router ``src/skills/setup/SKILL.md`` is a +Message-block dispatcher reached via ``/connect workday`` (its Workday branch in +``connect/step1.md`` delegates here; the standalone ``/setup-workday`` command was +retired). If a routed file path drifts (renamed/typo'd playbook, moved template) +the orchestrator silently dead-ends at setup time. This test pins the router's +wiring so that drift is caught at CI time instead. +""" from __future__ import annotations +import re from pathlib import Path - +# tests/setup/test_setup_router.py -> repo root is parents[2]. _REPO_ROOT = Path(__file__).resolve().parents[2] _SOLUTION = _REPO_ROOT / "solutions" / "ess-maker-skills" -_BOUNDARY = _SOLUTION / "src" / "skills" / "setup" / "SKILL.md" +_ROUTER = _SOLUTION / "src" / "skills" / "setup" / "SKILL.md" _CONNECT_STEP1 = _SOLUTION / "src" / "skills" / "connect" / "step1.md" _CONNECT_SKILL = _SOLUTION / "src" / "skills" / "connect" / "SKILL.md" _OLD_PROMPT = _SOLUTION / ".github" / "prompts" / "setup-workday.prompt.md" -_MONOLITH_DIR = _SOLUTION / "src" / "skills" / "connect" / "workday" -_WORKDAY_PLAYBOOKS = ( - "provision-power-platform-environment.md", - "install-ess.md", - "provision-workday-entra-app.md", - "configure-workday-tenant.md", - "install-workday-extension-pack.md", - "install-workday-ootb-topics.md", - "create-new-topic.md", +_WORKDAY_PROVIDER_DIR = _SOLUTION / "src" / "skills" / "connect" / "workday" +_TEMPLATE = _SOLUTION / "src" / "skills" / "setup" / "workday" / "tasks.md" +_SKILL1 = ( + _SOLUTION / "src" / "skills" / "setup" / "workday" + / "provision-power-platform-environment.md" +) +_SKILL2 = ( + _SOLUTION / "src" / "skills" / "setup" / "workday" / "install-ess.md" +) +_SKILL3 = ( + _SOLUTION / "src" / "skills" / "setup" / "workday" + / "provision-workday-entra-app.md" +) +_SKILL4 = ( + _SOLUTION / "src" / "skills" / "setup" / "workday" + / "configure-workday-tenant.md" ) +_SKILL5 = ( + _SOLUTION / "src" / "skills" / "setup" / "workday" + / "install-workday-extension-pack.md" +) +_SKILL6 = ( + _SOLUTION / "src" / "skills" / "setup" / "workday" + / "create-new-topic.md" +) +_OOTB = ( + _SOLUTION / "src" / "skills" / "setup" / "workday" + / "install-workday-ootb-topics.md" +) + +# Backtick-quoted repo-relative markdown paths the router points at. +_PATH_RE = re.compile(r"`(src/skills/[^`]+?\.md)`") + + +class TestSetupRouter: + def test_router_exists(self): + assert _ROUTER.is_file(), f"missing setup router: {_ROUTER}" + + def test_connect_workday_branch_routes_by_architecture_and_install_state(self): + # DA routes to setup; an installed CEA extension routes to the + # provider lifecycle; a fresh CEA setup still routes to setup. + text = _CONNECT_STEP1.read_text(encoding="utf-8") + assert "src/skills/setup/SKILL.md" in text, ( + "connect/step1.md Workday branch must route to the setup orchestrator" + ) + assert "src/skills/connect/workday/SKILL.md" in text, ( + "connect/step1.md must route an installed CEA extension to the " + "provider lifecycle" + ) + assert "WD-DA-PKG-001" in text + assert "WD-PKG-001" in text + assert "connect/workday/step" not in text, ( + "connect/step1.md must not reference the retired connect/workday monolith" + ) + + def test_setup_workday_command_removed(self): + # The /setup-workday command was retired; /connect workday is the sole + # entry point to the orchestrator. + assert not _OLD_PROMPT.exists(), ( + "the /setup-workday prompt must be removed (retired command)" + ) + + def test_connect_workday_provider_uses_contract_not_step_monolith(self): + assert (_WORKDAY_PROVIDER_DIR / "contract.json").is_file() + assert (_WORKDAY_PROVIDER_DIR / "SKILL.md").is_file() + assert not list(_WORKDAY_PROVIDER_DIR.glob("step*.md")), ( + "the provider lifecycle must not restore the retired step-file monolith" + ) + skill = _CONNECT_SKILL.read_text(encoding="utf-8") + assert "connect/workday/step" not in skill, ( + "connect/SKILL.md must not reference the retired monolith step files" + ) + # ServiceNow routing must remain intact. + assert "src/skills/connect/servicenow/" in skill, ( + "connect/SKILL.md must still route ServiceNow" + ) + + def test_router_dispatches_to_skill1_playbook(self): + text = _ROUTER.read_text(encoding="utf-8") + assert ( + "src/skills/setup/workday/provision-power-platform-environment.md" in text + ), "router must dispatch S1.1/S1.2 to the skill-1 playbook" + assert _SKILL1.is_file(), f"missing skill-1 playbook: {_SKILL1}" + + def test_router_dispatches_to_skill2_playbook(self): + text = _ROUTER.read_text(encoding="utf-8") + assert ( + "src/skills/setup/workday/install-ess.md" in text + ), "router must dispatch S2.1 to the skill-2 install-ess playbook" + assert _SKILL2.is_file(), f"missing skill-2 playbook: {_SKILL2}" + # Every skill (S1-S6) is now wired: no not-available-yet stub remains. + assert "not available yet" not in text, ( + "router must not carry a not-available-yet stub once skill-6 is wired" + ) + + def test_router_dispatches_to_skill3_playbook(self): + text = _ROUTER.read_text(encoding="utf-8") + assert ( + "src/skills/setup/workday/provision-workday-entra-app.md" in text + ), "router must dispatch S3.1-S3.7 to the skill-3 Entra-app playbook" + assert _SKILL3.is_file(), f"missing skill-3 playbook: {_SKILL3}" + # S3.x must no longer be in the not-available-yet stub range. + assert "S3.1 through S6.2 — not available yet" not in text, ( + "router stub range must start at S5.1 once skill-4 is wired" + ) + + def test_router_dispatches_to_skill4_playbook(self): + text = _ROUTER.read_text(encoding="utf-8") + assert ( + "src/skills/setup/workday/configure-workday-tenant.md" in text + ), "router must dispatch S4.1-S4.4 to the skill-4 tenant playbook" + assert _SKILL4.is_file(), f"missing skill-4 playbook: {_SKILL4}" + # S4.x must no longer be in the not-available-yet stub range. + assert "S4.1 through S6.2 — not available yet" not in text, ( + "router stub range must start at S5.1 once skill-4 is wired" + ) + + def test_router_dispatches_to_skill5_playbook(self): + text = _ROUTER.read_text(encoding="utf-8") + assert ( + "src/skills/setup/workday/install-workday-extension-pack.md" in text + ), "router must dispatch S5.1-S5.8 to the skill-5 extension-pack playbook" + assert _SKILL5.is_file(), f"missing skill-5 playbook: {_SKILL5}" + # S5.x must no longer be in the not-available-yet stub range. + assert "S5.1 through S6.2 — not available yet" not in text, ( + "router stub range must start at S6.1 once skill-5 is wired" + ) + + def test_router_dispatches_to_skill6_playbook(self): + text = _ROUTER.read_text(encoding="utf-8") + assert ( + "src/skills/setup/workday/create-new-topic.md" in text + ), "router must dispatch S6.1-S6.3 to the skill-6 create-new-topic playbook" + assert _SKILL6.is_file(), f"missing skill-6 playbook: {_SKILL6}" + # skill-6 is the last skill: the not-available-yet stub must be gone. + assert "not available yet" not in text, ( + "router must not carry a not-available-yet stub once skill-6 is wired" + ) + def test_router_offers_optional_ootb_topics_between_skill5_and_skill6(self): + # The optional ready-made-topics installer is offered after skill-5 and + # before skill-6. It is an opt-in interstitial (not a tracked S-row), + # gated by ootbTopics.state so a resumed setup never re-prompts. + text = _ROUTER.read_text(encoding="utf-8") + assert ( + "src/skills/setup/workday/install-workday-ootb-topics.md" in text + ), "router must offer the optional OOTB-topics installer playbook" + assert _OOTB.is_file(), f"missing OOTB installer playbook: {_OOTB}" + assert "ootbTopics" in text, ( + "router must gate the optional offer on ootbTopics.state so it is " + "not re-prompted after install/decline" + ) + # The offer must sit between the skill-5 and skill-6 dispatch blocks. + pack_idx = text.index("install-workday-extension-pack.md") + ootb_idx = text.index("install-workday-ootb-topics.md") + create_idx = text.index("create-new-topic.md") + assert pack_idx < ootb_idx < create_idx, ( + "the OOTB-topics offer must appear after the skill-5 dispatch and " + "before the skill-6 dispatch" + ) -def test_connect_workday_routes_to_hybrid_boundary() -> None: - step1 = _CONNECT_STEP1.read_text(encoding="utf-8") - connect = _CONNECT_SKILL.read_text(encoding="utf-8") - normalized_step1 = " ".join(step1.split()) + def test_ootb_offer_gated_on_persistent_state_not_transient_resume(self): + # Regression: the offer used to be gated on the transient "first + # non-done row is S6.1" resume condition, so once S6 completed the + # offer was never shown again and was silently skipped in the S5->S6 + # handoff. It must instead be anchored to the persistent + # ootbTopics.state flag in two places: (1) a gate at the S6 dispatch + # entry (runs before skill-6), and (2) an All-done safety net for a + # setup that reached S6 before the offer was ever shown. + text = _ROUTER.read_text(encoding="utf-8") - assert "src/skills/setup/SKILL.md" in step1 - assert "hybrid-extension availability boundary" in normalized_step1 - assert "connect/workday/step" not in step1 - assert "connect/workday/step" not in connect - assert "src/skills/connect/servicenow/" in connect + # (1) The S6 dispatch block gates on ootbTopics.state BEFORE it reads + # the skill-6 playbook. + s6_idx = text.index("### S6.1 through S6.3") + create_idx = text.index("create-new-topic.md") + s6_gate = text[s6_idx:create_idx] + assert "ootbTopics.state" in s6_gate, ( + "the S6 dispatch block must run the OOTB offer gated on " + "ootbTopics.state BEFORE reading create-new-topic.md, so the offer " + "cannot be skipped in the S5->S6 handoff" + ) + # (2) The All-done path rescues a setup that never saw the offer. + done_idx = text.index("If **every** item is `done`") + assert "ootbTopics" in text[done_idx:done_idx + 600], ( + "the All-done path must run the OOTB offer as a safety net when " + "ootbTopics.state is unset, so a setup that reached S6 before the " + "offer was shown still gets it" + ) -def test_hybrid_boundary_is_non_mutating() -> None: - text = _BOUNDARY.read_text(encoding="utf-8") + def test_router_renders_from_template(self): + text = _ROUTER.read_text(encoding="utf-8") + assert "src/skills/setup/workday/tasks.md" in text, ( + "router must render the working copy from the checklist template" + ) + assert _TEMPLATE.is_file(), f"missing checklist template: {_TEMPLATE}" - assert "Hybrid Workday extension setup is not available" in text - assert "Do not run the retained Workday setup playbooks" in text - assert "no Dataverse environment, solution, connection" in text - for playbook in _WORKDAY_PLAYBOOKS: - assert playbook not in text + def test_router_resumes_from_setupstatus(self): + text = _ROUTER.read_text(encoding="utf-8") + assert "setupStatus" in text, ( + "router must resume from setupStatus (durable source of truth)" + ) + assert ".local/connect/workday/config.json" in text, ( + "router must read setupStatus from .local/connect/workday/config.json" + ) + def test_router_uses_local_state_paths(self): + # DD-CW5: state lives under .local/, never the plan's stale my/ prefix. + text = _ROUTER.read_text(encoding="utf-8") + assert ".local/setup/workday/tasks.md" in text + assert "my/setup/workday" not in text -def test_retained_workday_playbooks_are_not_publicly_routed() -> None: - playbook_dir = _SOLUTION / "src" / "skills" / "setup" / "workday" - boundary = _BOUNDARY.read_text(encoding="utf-8") - connect = _CONNECT_SKILL.read_text(encoding="utf-8") - step1 = _CONNECT_STEP1.read_text(encoding="utf-8") + def test_all_referenced_paths_resolve(self): + text = _ROUTER.read_text(encoding="utf-8") + referenced = set(_PATH_RE.findall(text)) + assert referenced, "router references no src/skills paths — wiring changed?" + missing = [p for p in sorted(referenced) if not (_SOLUTION / p).is_file()] + assert not missing, f"router references missing files: {missing}" - for playbook in _WORKDAY_PLAYBOOKS: - assert (playbook_dir / playbook).is_file(), playbook - assert playbook not in boundary - assert playbook not in connect - assert playbook not in step1 + def test_router_checklist_mirrors_template(self): + # The router renders the resume checklist grouped exactly like the + # template. Its Message block duplicates the template's group headings and + # item titles, so pin parity: every heading/title in the template must + # appear in the router, or the displayed checklist silently drifts. + router = _ROUTER.read_text(encoding="utf-8") + template = _TEMPLATE.read_text(encoding="utf-8") + titles = re.findall( + r"^- \[[ xX]\]\s+\*\*(.+?)\*\*\s+\u2014", template, re.MULTILINE + ) + assert len(titles) == 25, ( + f"expected 25 item titles in template, parsed {len(titles)}" + ) + missing_titles = [t for t in titles if t not in router] + assert not missing_titles, ( + f"router checklist is missing item titles from the template: {missing_titles}" + ) -def test_retired_workday_entry_points_remain_absent() -> None: - assert not _OLD_PROMPT.exists() - assert not _MONOLITH_DIR.exists() + headings = re.findall(r"^###\s+\d+\.\s+(.+?)\s*$", template, re.MULTILINE) + assert headings, "no group headings parsed from the template" + missing_headings = [h for h in headings if h not in router] + assert not missing_headings, ( + f"router checklist is missing group headings from the template: {missing_headings}" + ) From af0707ba726c36636023c76274c914e68beb6b18 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Thu, 17 Sep 2026 13:00:10 -0700 Subject: [PATCH 05/17] Add ALM guidance and fix wd connect steps --- solutions/ess-maker-skills/README.md | 24 ++ .../alm/Enable-CosmosDAFlowAuthorization.ps1 | 310 ++++++++++++++++++ .../ess-maker-skills/scripts/alm/README.md | 202 ++++++++++++ .../scripts/flightcheck/checks/workday_da.py | 94 ++---- .../scripts/flightcheck/cli.py | 1 - .../scripts/install_workday_da_extension.py | 11 +- .../src/skills/connect/SKILL.md | 18 +- .../src/skills/connect/step1.md | 87 ++++- .../src/skills/connect/steps.md | 3 +- .../src/skills/setup/workday-da/SKILL.md | 103 ++++-- .../workday-da/configure-power-platform.md | 223 +++++++++++++ .../setup/workday-da/install-extension.md | 61 ++-- .../setup/workday-da/shared/config-schema.md | 34 +- .../src/skills/setup/workday-da/tasks.md | 44 ++- .../setup/workday-da/verify-connection.md | 71 ++-- tests/flightcheck/checks/test_workday_da.py | 33 +- tests/flightcheck/test_cli.py | 6 + .../test_install_workday_da_extension.py | 27 +- tests/setup/test_setup_router.py | 62 ++++ 19 files changed, 1169 insertions(+), 245 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 create mode 100644 solutions/ess-maker-skills/scripts/alm/README.md create mode 100644 solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md diff --git a/solutions/ess-maker-skills/README.md b/solutions/ess-maker-skills/README.md index 3ea6feda5..fb7675e1e 100644 --- a/solutions/ess-maker-skills/README.md +++ b/solutions/ess-maker-skills/README.md @@ -245,6 +245,9 @@ Connect your agent to ServiceNow for IT tickets, HR cases, and service catalog i Connect your agent to Workday for employee data, compensation, time off, and org lookups. Run `/connect workday` to start. +For Declarative Agents, this release supports the **ESS HR Agent**. Workday +integration with the ESS IT Agent is not supported in this release. + **Two supported install paths** — the kit detects which one applies and routes automatically: - **Simplified** (Microsoft's default for new installs) — just one Workday connection (OAuthUser via Entra ID) plus Dataverse. No ISU service accounts, security groups, or custom reports. User context comes from the Workday REST `/workers/me` endpoint. @@ -268,6 +271,27 @@ Connect your agent to Workday for employee data, compensation, time off, and org **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 +[`scripts/alm/README.md`](scripts/alm/README.md) for the required parameters, +safe preview, execution, and verification procedure. + +For ESS DA HR in development, `/connect workday` also guides the maker through +the OAuthUser and Dataverse references, shared connection parameters, +stale-connection recovery, flow enablement, bot-to-flow authorization, V2 +employee context, topic selection, firewall readiness, and a signed-in +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. + **What you can build after connecting:** - Look up employee information, compensation, service anniversary, cost center - Check time off balances and request time off diff --git a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 new file mode 100644 index 000000000..faa5c5bc3 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 @@ -0,0 +1,310 @@ +<# +.SYNOPSIS + Provisions the Dataverse records that allow Power Automate cloud flows to be installed and + invoked by a Cosmos-backed Declarative Agent (template gptagent-1.0.0, schema gptagent_*). + +.DESCRIPTION + Flow-RP resolves the "mcsbot" delegated-authorization principal exclusively from Dataverse. + A Cosmos-backed agent has no Dataverse 'bot' row, so that lookup fails and flow install / + invoke return 403. + + Flow-RP never reads the 'bot' table. It only requires: + 1. a 'delegatedauthorization' row of providertype 3 (MCSBot) carrying the bot GUID + 2. an ACCESS team (teamtype 1) linked to that delegated authorization + 3. that team shared on each workflow the agent calls, with at least WriteAccess + + This script creates those three things idempotently. It is safe to re-run: existing records + are detected and reused, and access is only granted where missing. + +.PARAMETER OrgUrl + Dataverse organization URL, e.g. https://contoso.crm.dynamics.com + +.PARAMETER BotId + The agent's CdsBotId (GUID). + +.PARAMETER WorkflowId + One or more workflow GUIDs (the 'workflowid' of each cloud flow the agent calls). + +.PARAMETER TeamName + Optional display name for the access team. + +.PARAMETER AdministratorId + Optional systemuserid to own the team. Must hold System Administrator. Auto-resolved if omitted. + +.PARAMETER BusinessUnitId + Optional business unit. Defaults to the org's root business unit. + +.PARAMETER AccessMask + Access rights granted to the team on each workflow. WriteAccess is the significant one - + Flow-RP maps a mask containing WriteAccess to UserAccessType.Owner. + +.PARAMETER WhatIf + Report what would change without writing anything. + +.EXAMPLE + .\Enable-CosmosDAFlowAuthorization.ps1 ` + -OrgUrl https://contoso.crm.dynamics.com ` + -BotId 923db1bf-2512-4378-a43b-71b944ea717c ` + -WorkflowId 3164dae9-3a2b-5843-98dd-e62bb5123324 + +.NOTES + Requires Azure CLI, signed in to an account with Dataverse system-administrator rights on + the target org. Run Steps in the ESS runbook order: this script covers steps 2-4. +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)][string] $OrgUrl, + [Parameter(Mandatory = $true)][guid] $BotId, + [Parameter(Mandatory = $true)][guid[]] $WorkflowId, + [string] $TeamName, + [guid] $AdministratorId, + [guid] $BusinessUnitId, + [string] $AccessMask = "ReadAccess,WriteAccess,AppendAccess,AppendToAccess,ShareAccess" +) + +$ErrorActionPreference = 'Stop' +$OrgUrl = $OrgUrl.TrimEnd('/') +$ApiBase = "$OrgUrl/api/data/v9.1" + +# providertype option-set value for MCSBot on the delegatedauthorization entity. +$ProviderTypeMcsBot = 3 +# teamtype option-set value for an Access team. Owner teams (0) are rejected by the platform +# with 0x80097207 when linked to a delegated authorization. +$TeamTypeAccess = 1 + +function Write-Step { param([string]$m) Write-Host "`n=== $m" -ForegroundColor Cyan } +function Write-Ok { param([string]$m) Write-Host " [ok] $m" -ForegroundColor Green } +function Write-Reuse { param([string]$m) Write-Host " [reuse] $m" -ForegroundColor DarkGray } +function Write-Create { param([string]$m) Write-Host " [create] $m" -ForegroundColor Yellow } +function Write-Fail { param([string]$m) Write-Host " [FAIL] $m" -ForegroundColor Red } + +function Get-DataverseToken { + param([string]$Resource) + try { + $tok = az account get-access-token --resource $Resource --query accessToken -o tsv 2>$null + } catch { + throw "Azure CLI token acquisition failed. Run 'az login' and ensure the account has access to $Resource." + } + if ([string]::IsNullOrWhiteSpace($tok)) { + throw "Could not acquire a Dataverse token for $Resource. Check 'az account show' and that the correct subscription/tenant is selected." + } + return $tok +} + +function Invoke-Dv { + param( + [ValidateSet('GET', 'POST', 'PATCH')][string]$Method = 'GET', + [Parameter(Mandatory = $true)][string]$Path, + $Body + ) + $uri = if ($Path -match '^https?://') { $Path } else { "$ApiBase/$($Path.TrimStart('/'))" } + $headers = @{ + Authorization = "Bearer $script:Token" + Accept = 'application/json' + 'OData-MaxVersion' = '4.0' + 'OData-Version' = '4.0' + } + try { + if ($null -ne $Body) { + $json = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 10 } + return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -Body $json -ContentType 'application/json' + } + return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers + } catch { + # PowerShell 7: the response body lives on ErrorDetails. $_.Exception.Response.GetResponseStream() + # does NOT exist on HttpResponseMessage and silently hides the real Dataverse error. + $detail = $_.ErrorDetails.Message + if ($detail) { + try { $parsed = ($detail | ConvertFrom-Json).error.message } catch { $parsed = $detail } + throw "Dataverse $Method $uri failed: $parsed" + } + throw + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step "Connecting to $OrgUrl" +$script:Token = Get-DataverseToken -Resource $OrgUrl +$who = Invoke-Dv -Path 'WhoAmI' +Write-Ok "Authenticated. UserId $($who.UserId), OrgId $($who.OrganizationId)" + +# -------------------------------------------------------------------------------------------- +Write-Step "Step 2/4 - delegatedauthorization for bot $BotId" + +$daFilter = "delegatedauthorizations?`$select=delegatedauthorizationid,name,providertype&`$filter=botid eq '$BotId'" +$daExisting = (Invoke-Dv -Path $daFilter).value + +if ($daExisting.Count -gt 0) { + $daId = $daExisting[0].delegatedauthorizationid + Write-Reuse "delegatedauthorization $daId (providertype $($daExisting[0].providertype))" + if ($daExisting[0].providertype -ne $ProviderTypeMcsBot) { + Write-Fail "Existing delegated authorization has providertype $($daExisting[0].providertype); expected $ProviderTypeMcsBot (MCSBot). Resolve manually." + exit 1 + } +} else { + $daBody = @{ + name = if ($TeamName) { "$TeamName delegated auth" } else { "Cosmos DA delegated auth ($BotId)" } + providertype = $ProviderTypeMcsBot + botid = $BotId.ToString() + } + if ($PSCmdlet.ShouldProcess("delegatedauthorization for bot $BotId", 'Create')) { + $r = Invoke-Dv -Method POST -Path 'delegatedauthorizations?$select=delegatedauthorizationid' -Body $daBody + $daId = $r.delegatedauthorizationid + Write-Create "delegatedauthorization $daId" + } else { + Write-Create "would create delegatedauthorization (providertype $ProviderTypeMcsBot)" + $daId = '' + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step "Step 3/4 - access team linked to the delegated authorization" + +# Query exactly the way Flow-RP does (XrmRequestFactory.GetTeamsForBotIdUri), so a hit here +# means Flow-RP will resolve the bot successfully. +$teamFilter = "teams?`$expand=delegatedauthorizationid&`$filter=delegatedauthorizationid/botid eq '$BotId'&`$select=teamid,name,teamtype" +$teamExisting = (Invoke-Dv -Path $teamFilter).value + +if ($teamExisting.Count -gt 0) { + $teamId = $teamExisting[0].teamid + Write-Reuse "team $teamId '$($teamExisting[0].name)' (teamtype $($teamExisting[0].teamtype))" + if ($teamExisting[0].teamtype -ne $TeamTypeAccess) { + Write-Fail "Existing team is teamtype $($teamExisting[0].teamtype); must be $TeamTypeAccess (Access). Resolve manually." + exit 1 + } +} else { + if (-not $BusinessUnitId) { + $bu = (Invoke-Dv -Path 'businessunits?$select=businessunitid,name&$filter=parentbusinessunitid eq null').value + if (-not $bu) { throw 'Could not resolve the root business unit. Pass -BusinessUnitId explicitly.' } + $BusinessUnitId = $bu[0].businessunitid + Write-Ok "Resolved root business unit $BusinessUnitId ($($bu[0].name))" + } + + if (-not $AdministratorId) { + # The team administrator must hold System Administrator, otherwise team creation fails + # with 0x80041d0a "The team administrator does not have privilege read team." + $admins = (Invoke-Dv -Path ("systemuserrolescollection?`$select=systemuserid&`$top=0")) 2>$null + $roleQ = 'roles?$select=roleid&$filter=name eq ''System Administrator''' + $role = (Invoke-Dv -Path $roleQ).value + if (-not $role) { throw 'Could not locate the System Administrator role. Pass -AdministratorId explicitly.' } + $roleId = $role[0].roleid + $userQ = "systemusers?`$select=systemuserid,fullname&`$filter=systemuserroles_association/any(r:r/roleid eq $roleId) and isdisabled eq false&`$top=1" + $admin = (Invoke-Dv -Path $userQ).value + if (-not $admin) { throw 'Could not locate an enabled System Administrator. Pass -AdministratorId explicitly.' } + $AdministratorId = $admin[0].systemuserid + Write-Ok "Resolved team administrator $AdministratorId ($($admin[0].fullname))" + } + + $teamBody = @{ + name = if ($TeamName) { $TeamName } else { "Cosmos DA bot team ($BotId)" } + teamtype = $TeamTypeAccess + 'businessunitid@odata.bind' = "/businessunits($BusinessUnitId)" + 'administratorid@odata.bind' = "/systemusers($AdministratorId)" + 'delegatedauthorizationid@odata.bind' = "/delegatedauthorizations($daId)" + } + if ($PSCmdlet.ShouldProcess("access team for bot $BotId", 'Create')) { + $r = Invoke-Dv -Method POST -Path 'teams?$select=teamid' -Body $teamBody + $teamId = $r.teamid + Write-Create "team $teamId" + } else { + Write-Create "would create access team (teamtype $TeamTypeAccess)" + $teamId = '' + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step "Step 4/4 - share each workflow with the team" + +foreach ($wf in $WorkflowId) { + + $wfRow = (Invoke-Dv -Path "workflows?`$select=workflowid,name,statecode&`$filter=workflowid eq $wf").value + if (-not $wfRow) { + Write-Fail "workflow $wf not found in this organization - skipping" + continue + } + $wfName = $wfRow[0].name + + $shared = $false + if ($teamId -ne '') { + # RetrieveSharedPrincipalsAndAccess is a FUNCTION, not an action. POSTing it returns + # 0x80060888 "Resource not found for the segment". + $tid = '{"@odata.id":"workflows(' + $wf + ')"}' + $spUri = "$ApiBase/RetrieveSharedPrincipalsAndAccess(Target=@tid)?@tid=" + [uri]::EscapeDataString($tid) + $sp = Invoke-Dv -Path $spUri + $match = $sp.PrincipalAccesses | Where-Object { + $_.Principal.'@odata.type' -match 'team' -and $_.Principal.ownerid -eq $teamId + } + if ($match) { + if ($match.AccessMask -match 'WriteAccess') { + Write-Reuse "'$wfName' already shared with the team (mask: $($match.AccessMask))" + $shared = $true + } else { + Write-Host " [fix] '$wfName' shared but mask lacks WriteAccess ($($match.AccessMask)) - re-granting" -ForegroundColor Yellow + } + } + } + + if (-not $shared) { + $grant = @{ + Target = @{ '@odata.type' = 'Microsoft.Dynamics.CRM.workflow'; workflowid = $wf.ToString() } + PrincipalAccess = @{ + Principal = @{ '@odata.type' = 'Microsoft.Dynamics.CRM.team'; teamid = $teamId } + AccessMask = $AccessMask + } + } + if ($PSCmdlet.ShouldProcess("workflow '$wfName' ($wf)", "Grant $AccessMask to team $teamId")) { + # GrantAccess is idempotent enough in practice, but ModifyAccess is the documented + # call when a share already exists. Try GrantAccess, fall back to ModifyAccess. + try { Invoke-Dv -Method POST -Path 'GrantAccess' -Body $grant | Out-Null } + catch { Invoke-Dv -Method POST -Path 'ModifyAccess' -Body $grant | Out-Null } + Write-Create "'$wfName' shared with team ($AccessMask)" + } else { + Write-Create "would share '$wfName' with the team" + } + } +} + +# -------------------------------------------------------------------------------------------- +Write-Step 'Verification - replaying the exact lookups Flow-RP performs' + +$ok = $true + +$teamCheck = (Invoke-Dv -Path $teamFilter).value +if ($teamCheck.Count -eq 1) { + Write-Ok "GetTeamsForBotId returns 1 team ($($teamCheck[0].teamid))" +} else { + Write-Fail "GetTeamsForBotId returned $($teamCheck.Count) teams; Flow-RP takes the first and expects exactly one" + if ($teamCheck.Count -eq 0) { $ok = $false } +} + +foreach ($wf in $WorkflowId) { + $tid = '{"@odata.id":"workflows(' + $wf + ')"}' + $spUri = "$ApiBase/RetrieveSharedPrincipalsAndAccess(Target=@tid)?@tid=" + [uri]::EscapeDataString($tid) + try { $sp = Invoke-Dv -Path $spUri } catch { Write-Fail "$wf - could not read shares: $_"; $ok = $false; continue } + + $match = $sp.PrincipalAccesses | Where-Object { + $_.Principal.'@odata.type' -match 'team' -and $_.Principal.ownerid -eq $teamCheck[0].teamid + } + if ($match -and $match.AccessMask -match 'WriteAccess') { + # XrmPrincipalAccessExtensions.ToUserAccessType: a mask containing WriteAccess maps to + # UserAccessType.Owner, the maximum, which is unambiguously sufficient for install. + Write-Ok "$wf resolves to UserAccessType.Owner (mask: $($match.AccessMask))" + } else { + Write-Fail "$wf is NOT shared with the team with WriteAccess" + $ok = $false + } +} + +Write-Host '' +if ($ok) { + Write-Host 'Dataverse authorization is in place.' -ForegroundColor Green + Write-Host 'Remaining steps, outside this script:' -ForegroundColor Green + Write-Host ' - Ensure the flow connection reference has connectionparametersetconfig populated (UX workstream).' + Write-Host ' - Ensure delegated authorization is NOT suppressed for Cosmos-backed agents (product code workstream).' + Write-Host ' - Clear cached user flow state before retesting: POST user-connections with connectionId null, or use a fresh user.' + exit 0 +} else { + Write-Host 'Verification FAILED - see [FAIL] lines above.' -ForegroundColor Red + exit 1 +} \ No newline at end of file diff --git a/solutions/ess-maker-skills/scripts/alm/README.md b/solutions/ess-maker-skills/scripts/alm/README.md new file mode 100644 index 000000000..bbd46915c --- /dev/null +++ b/solutions/ess-maker-skills/scripts/alm/README.md @@ -0,0 +1,202 @@ +# ESS DA Workday authorization in Test and Production + +`Enable-CosmosDAFlowAuthorization.ps1` is the idempotent post-deployment script +for ESS Declarative Agent flow authorization. It creates or reuses the +Dataverse delegated authorization and access team required by a Cosmos-backed +ESS Declarative Agent, shares the target Workday cloud flows with that team, +and verifies the resulting access. Run the checked-in script as documented; +do not modify its implementation. + +## PowerShell is the supported implementation + +Run the checked-in `.ps1` directly. Do not port or replace the partner +implementation with Python. ADK orchestration may resolve parameters, preview +the operation, and invoke the script, but the authorization implementation +remains in the unchanged PowerShell file. The repository already uses +PowerShell for its Windows installers and CI smoke tests, and this script +parses under both PowerShell 7 and Windows PowerShell 5.1. + +PowerShell 7 (`pwsh`) is preferred. The script also requires Azure CLI (`az`) in +the same process environment. A normal Git clone does not require changing the +machine execution policy. If organizational policy prevents local scripts from +running, use the customer-approved signed-script or pipeline process rather +than weakening the machine-wide execution policy. + +This release supports the **ESS DA HR Agent** only. Do not run this procedure +for the ESS DA IT Agent. + +## When to run it + +In the development environment, `/connect workday` owns the guided setup and +can invoke this authorization operation after the ESS DA HR agent and Workday +flows exist. + +Makers are not expected to run `/connect workday` in Test or Production. +Instead, a Power Platform administrator runs this script as a post-deployment +step in each target environment: + +1. Deploy the ESS DA HR agent. +2. Install or upgrade the Workday extension package. +3. Apply the target environment's Workday and Dataverse connections. +4. Capture the target environment parameters below. +5. Run the script with `-WhatIf`. +6. Review the preview and obtain the normal deployment approval. +7. Run the script without `-WhatIf`. +8. Complete the connection-binding and runtime checks for that environment. + +Run the operation separately in Test and Production. Never reuse Dev GUIDs. + +## Prerequisites + +- PowerShell 7 or Windows PowerShell. +- Azure CLI (`az`) installed. +- An Azure CLI sign-in to the Entra tenant that owns the target environment. +- Dataverse System Administrator access in the target environment. +- The ESS DA HR agent and Workday extension package already deployed. +- The Workday cloud flows already present in the target environment. + +The script acquires a Dataverse token through Azure CLI. Do not pass an access +token, password, client secret, or Workday credential to the script. + +## Required parameters + +| Parameter | Meaning | How to obtain it | +| --- | --- | --- | +| `OrgUrl` | Target Dataverse organization URL | In the Power Platform admin center, open the target environment and copy its environment URL. Use the Test URL for Test and the Production URL for Production. | +| `BotId` | Target environment's ESS DA HR `CdsBotId` | Capture it from the ESS DA HR deployment or installation output for that target environment. If deployment is automated, expose it as a pipeline output or environment-scoped variable. Do not use the Dev bot ID. | +| `WorkflowId` | One or more target Workday cloud-flow `workflowid` GUIDs | In the target environment, open the installed Workday solution, list its cloud flows, and capture the GUID for every flow the ESS DA HR agent invokes. A flow's GUID is also the `flowId` referenced by the agent topic and the Dataverse `workflowid`. Do not include unrelated environment flows. | + +Optional parameters: + +| Parameter | Recommendation | +| --- | --- | +| `TeamName` | Use a stable target-specific name such as `ESS DA HR Workday - Test`. | +| `AdministratorId` | Omit unless your governance process requires a specific enabled System Administrator to own the team. | +| `BusinessUnitId` | Omit to let the script use the root business unit. | +| `AccessMask` | Keep the script's default unless updated Microsoft product guidance specifies a replacement. | + +If the deployment process cannot unambiguously identify the target bot or +Workday flows, stop the deployment. Do not guess GUIDs. + +## Sign in and verify the target + +```powershell +az login --tenant "" +az account show +az account get-access-token ` + --resource "https://.crm.dynamics.com" ` + --query accessToken ` + --output tsv | Out-Null +``` + +The final command confirms that Azure CLI can acquire a token for the exact +target organization. It does not print or persist the token. + +## Prepare the target parameters + +Use values captured from the same target environment: + +```powershell +$OrgUrl = "https://.crm.dynamics.com" +$BotId = [guid]"" +$WorkflowIds = [guid[]]@( + "", + "" +) +$TeamName = "ESS DA HR Workday - Test" +``` + +For Production, replace every value with the Production environment value and +use a Production-specific team name. + +## Preview + +Run the authorization script with `-WhatIf` first: + +```powershell +& ".\Enable-CosmosDAFlowAuthorization.ps1" ` + -OrgUrl $OrgUrl ` + -BotId $BotId ` + -WorkflowId $WorkflowIds ` + -TeamName $TeamName ` + -WhatIf +``` + +Review that the output targets: + +- the intended Dataverse organization; +- the intended ESS DA HR bot; +- only the expected Workday workflows; +- an access team, not an owner team. + +### New-environment `-WhatIf` behavior + +The authorization script performs its final live verification after the +preview. In a new environment where the delegated authorization, access team, +or workflow shares do not exist yet, `-WhatIf` intentionally does not create +them. The final verification therefore prints `[FAIL]` for those not-yet-created +records and exits with code `1`. + +For the preview only, this is expected when all of the following are true: + +- each intended create/share operation is shown as `would create` or + `would share`; +- the target organization, bot, team type, and workflows are correct; +- the only `[FAIL]` lines describe records that the preview intentionally did + not create. + +Any authentication, lookup, permission, wrong-target, missing-workflow, +unexpected existing-record, or Dataverse request error is a real preview +failure and must be resolved before apply. The real apply run must still meet +the strict success criteria below. + +## Apply + +After deployment approval, run the same command without `-WhatIf`: + +```powershell +& ".\Enable-CosmosDAFlowAuthorization.ps1" ` + -OrgUrl $OrgUrl ` + -BotId $BotId ` + -WorkflowId $WorkflowIds ` + -TeamName $TeamName +``` + +The script is idempotent. Re-running it reuses valid existing records and adds +only missing workflow access. + +## Successful result + +The command must exit with code `0` and end with: + +```text +Dataverse authorization is in place. +``` + +The verification output must also show: + +- one team returned for the target bot; +- every supplied Workday workflow shared with that team; +- an access mask containing `WriteAccess`. + +Treat any `[FAIL]` line or nonzero exit code as a deployment failure. +This strict rule applies to the real apply run. The documented new-environment +`-WhatIf` exception applies only to the preview and only under the conditions +listed above. + +## Remaining target-environment steps + +This script covers only DA bot-to-flow Dataverse authorization. Before release +sign-off, also confirm: + +- Workday and Dataverse connection references use the target environment's + connections; +- required connection parameter configuration is populated; +- Workday cloud flows are turned on; +- stale user flow-connection state is cleared, or validation uses a fresh + test user; +- a signed-in test user can complete a Workday scenario through the ESS DA HR + agent. + +Record the parameter source, script output, and final runtime result in the +deployment evidence for each environment. 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 a53bd0969..b33e2c684 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -4,9 +4,9 @@ """ ESS FlightCheck — Declarative Agent (DA) Workday Extension Validation. -Verifies that the Workday extension package for the **Declarative Agent** -(DA) flavor of Employee Self-Service is installed into the target Power -Platform environment (``setup/workday-da`` skill, step DA1.1). DA and CEA +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 @@ -20,31 +20,16 @@ from auth import query_all, AuthExpiredError # scripts/auth.py, on path via cli.py -# Parent (base agent) schema names for the two DA verticals that ship a -# Workday child package. The DA Hub bundle has no Workday child of its own — -# Workday is installed against the HR or IT vertical (see -# src/reference/solution-catalog.md, "## Parents" / "## Child packages"). -_DA_PARENT_SCHEMAS = { - "hr": "msdyn_copilotforemployeeselfservicedahr", - "it": "msdyn_copilotforemployeeselfservicedait", -} - -# The Workday child package schema for each DA vertical (from -# src/reference/solution-catalog.md, "## Child packages, connection -# references, and flows"). -_DA_WORKDAY_CHILD_SCHEMAS = { - "hr": "msdyn_essdahrworkday", - "it": "msdyn_essdaitworkday", -} - -_VERTICAL_LABEL = {"hr": "HR", "it": "IT"} +_DA_HR_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedahr" +_DA_IT_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedait" +_DA_HR_WORKDAY_CHILD_SCHEMA = "msdyn_essdahrworkday" _SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" -# All four fixed schema names this check ever needs to resolve in one -# round-trip: the two DA parents plus their two Workday children. -_ALL_TRACKED_SCHEMAS = tuple(_DA_PARENT_SCHEMAS.values()) + tuple( - _DA_WORKDAY_CHILD_SCHEMAS.values() +_ALL_TRACKED_SCHEMAS = ( + _DA_HR_PARENT_SCHEMA, + _DA_IT_PARENT_SCHEMA, + _DA_HR_WORKDAY_CHILD_SCHEMA, ) _SOLN_FILTER = " or ".join( f"uniquename eq '{schema}'" for schema in _ALL_TRACKED_SCHEMAS @@ -54,7 +39,7 @@ "https://learn.microsoft.com/en-us/microsoft-365/copilot/" "employee-self-service/install" ) -_DESCRIPTION = "Workday extension package installed for the DA ESS agent" +_DESCRIPTION = "Workday extension package installed for the DA ESS HR agent" def run_workday_da_checks(runner) -> list[CheckResult]: @@ -115,49 +100,38 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: s.get("uniquename", "").casefold(): s for s in all_solutions } - installed_verticals = [ - vertical - for vertical, parent_schema in _DA_PARENT_SCHEMAS.items() - if parent_schema in installed_names - ] - installed_labels = ", ".join( - _VERTICAL_LABEL[vertical] for vertical in installed_verticals - ) + hr_installed = _DA_HR_PARENT_SCHEMA in installed_names + it_installed = _DA_IT_PARENT_SCHEMA in installed_names + + if not hr_installed and it_installed: + return [_result( + Status.FAILED.value, + "An ESS DA IT agent is installed, but no ESS DA HR agent was found.", + remediation=( + "Workday integration with the ESS IT Agent is not supported " + "in this release. Contact your administrator." + ), + )] - if not installed_verticals: + if not hr_installed: return [_result( Status.FAILED.value, - "No DA Employee Self-Service base agent (HR or IT) was found in " - "this environment.", + "No ESS DA HR agent was found in this environment.", remediation=( - "Run /setup to install the DA Employee Self-Service base " - "agent first, then run /connect workday again." + "Run /setup to install the ESS DA HR agent first, then run " + "/connect workday again." ), )] - findings = [] - missing_verticals = [] - for vertical in installed_verticals: - child_schema = _DA_WORKDAY_CHILD_SCHEMAS[vertical] - label = _VERTICAL_LABEL[vertical] - child = installed_names.get(child_schema) - if child: - findings.append(f"{label}: {_describe_solution(child)}") - else: - missing_verticals.append(vertical) - - if missing_verticals: - missing_labels = ", ".join(_VERTICAL_LABEL[v] for v in missing_verticals) - installed_summary = f" Already installed — {'; '.join(findings)}." if findings else "" + child = installed_names.get(_DA_HR_WORKDAY_CHILD_SCHEMA) + if not child: return [_result( Status.FAILED.value, - f"The Workday extension package is not installed for the " - f"{missing_labels} edition of your DA Employee Self-Service " - f"agent. Detected DA base agent editions: " - f"{installed_labels}.{installed_summary}", + "The Workday extension package is not installed for the ESS DA HR " + "agent.", remediation=( "Open AppSource (or the Microsoft 365 admin center), find " - "the Workday extension for Employee Self-Service, and " + "the Workday extension for Employee Self-Service HR, and " "deploy it to this environment — the same place you " "installed the base agent. Wait for the install to finish, " "then re-run this check." @@ -166,8 +140,8 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: return [_result( Status.PASSED.value, - f"Detected DA base agent editions: {installed_labels}. " - f"DA Workday extension package installed: {'; '.join(findings)}.", + "ESS DA HR agent detected. DA Workday extension package installed: " + f"{_describe_solution(child)}.", )] diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 70377e0fb..53a108c3d 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -130,7 +130,6 @@ ("Workday Tenant", run_workday_tenant_checks), ("External Systems", run_external_systems_checks), ("Workday", run_workday_checks), - ("Workday DA", run_workday_da_checks), ("Workday Extension", run_workday_extension_checks), ("Workday Topics", run_topic_checks), ("Graph Connector KB", run_graph_connector_kb_checks), 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 6d998b594..47562016d 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -30,7 +30,7 @@ from auth import discover_tenant from flightcheck.checks.workday_da import ( - _DA_WORKDAY_CHILD_SCHEMAS as WORKDAY_DA_CHILD_SCHEMAS, + _DA_HR_WORKDAY_CHILD_SCHEMA, ) from flightcheck.powerplatform_client import PowerPlatformClient from flightcheck.pp_admin_client import PPAdminClient @@ -50,7 +50,7 @@ _wait_for_install, ) -_VERTICAL_LABEL = {"hr": "HR", "it": "IT"} +WORKDAY_DA_CHILD_SCHEMAS = {"hr": _DA_HR_WORKDAY_CHILD_SCHEMA} class ExtensionNotListedError(RuntimeError): @@ -91,6 +91,11 @@ def install_workday_da_extension( ``RuntimeError`` for other automation problems. """ env_url = env_url.rstrip("/") + if vertical != "hr": + raise ValueError( + "Workday integration with the ESS DA IT Agent is not supported " + "in this release." + ) schema_name = WORKDAY_DA_CHILD_SCHEMAS[vertical] tenant_id = discover_tenant(env_url) @@ -160,7 +165,7 @@ def main() -> None: "--vertical", required=True, choices=sorted(WORKDAY_DA_CHILD_SCHEMAS), - help="DA vertical: hr or it", + help="DA vertical (this release supports hr only)", ) args = parser.parse_args() diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index 05b34e7d9..d052da981 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -60,13 +60,17 @@ Workday routes by architecture before package detection: pack, topic) using the master checklist as a resume-aware spine. State: `.local/setup/workday/tasks.md` + `setupStatus` in `.local/connect/workday/config.json`. - - **DA agent** — the **DA Workday connect skill** - (`src/skills/setup/workday-da/SKILL.md`). It sequences four steps - (extension pack, Entra app, tenant config, configuration review) the same - resume-aware way. State: `.local/setup/workday-da/tasks.md` + - `setupStatus` in `.local/connect/workday-da/config.json`. This checklist - does not claim the DA live connection or agent path is ready; those checks - remain deferred until DA-scoped binding and runtime validations exist. + - **DA HR agent** — the **DA Workday connect skill** + (`src/skills/setup/workday-da/SKILL.md`). It sequences five steps + (extension package, Entra app, tenant config, Power Platform/agent + integration, and signed-in runtime validation) the same resume-aware way. + State: `.local/setup/workday-da/tasks.md` + `setupStatus` in + `.local/connect/workday-da/config.json`. DA-scoped settings that cannot be + queried reliably remain explicit manual/attestation gates; the provider is + not marked ready until a signed-in Workday scenario succeeds. + - **DA IT agent** — unsupported for Workday in this release. The router + stops before creating state or running any Workday lifecycle step and + directs the maker to contact their administrator. `src/skills/connect/step1.md` reads `.local/setup/config.json`'s `selected_products` to choose the correct installation path. diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index f8d0c35d8..48cfb683f 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -8,8 +8,9 @@ Do not rephrase, add commentary, or tell the user what tools you are calling. ## 1.1 — Check what's already connected Read `.local/config.json` when it exists and resolve `ACTIVE_AGENT_SLUG` from -`activeAgent`, falling back to `agent.slug`. Use this identity for every -agent-specific Workday state and validation below. +`activeAgent`, falling back to `agent.slug`. Resolve the matching active agent +record and retain its `schemaName` / architecture. Use this identity for every +agent-specific integration state and validation below. Build a list of connected integrations (if any): @@ -21,11 +22,10 @@ Build a list of connected integrations (if any): orchestrator owns this state), or - `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` exists and every phase is `done` (this specific CEA agent was wired to an - extension already installed elsewhere — see 1.3 below). - -The DA checklist deliberately does not count as "connected" yet. It confirms -package, Entra, and tenant configuration, but DA-scoped live connection -binding and agent-path validation are still deferred. + extension already installed elsewhere — see 1.3 below), or + - the active agent is the ESS DA HR Agent and + `.local/connect/workday-da/config.json` has `status: "ready"` with every + DA setup row through `DA5.1` in state `done`. --- @@ -67,6 +67,22 @@ Wait for the user to respond. ### If the user chose ServiceNow (1 or "servicenow") +Resolve `.local/setup/config.json` `selected_products` and the active agent +record from `.local/config.json`. If the active agent is a Declarative Agent, +or the installed inventory contains only `da.*` products, show: + +**Message:** + +ServiceNow integration with an ESS Declarative Agent isn't supported in this +release. Please contact your administrator. + +**End message.** + +Stop immediately. Do not create ServiceNow state or enter the ServiceNow +lifecycle. If both CEA and DA products exist and no active agent can be +resolved, ask the maker to select an agent and run `/connect servicenow` +again; never guess the architecture. + Check if `.local/connect/servicenow/steps.md` exists. **If it exists and all items are checked:** @@ -235,20 +251,61 @@ First find out which kind of ESS agent is in this environment. Read Stop here. -### DA agent +Resolve the active agent from `.local/config.json` (`activeAgent`, then the +matching entry in `agents`; fall back to `agent` only for the legacy +single-agent shape). Route by the resolved active agent's architecture before +consulting the installed-product inventory: + +- active `msdyn_copilotforemployeeselfservicedahr` or + `msdyn_copilotforemployeeselfservicedait` → use the DA rules below; +- active CEA agent → skip to **CEA agent** below, even if DA products are also + installed. + +If no active agent can be resolved, infer the architecture only when every +selected product belongs to the same architecture. If DA and CEA products are +both installed, stop and ask the maker to select the intended agent before +running `/connect workday` again. Never choose an architecture from whichever +product appears first. + +### DA HR agent + +Enter this branch only when the active agent is DA or the unresolved inventory +contains only `da.*` products. Use the active agent's `schemaName` and the +selected product inventory to determine the DA vertical. + +**ESS DA IT is not supported in this release.** If the active agent is +`msdyn_copilotforemployeeselfservicedait`, or the only selected DA vertical is +`da.essit`, show: + +**Message:** + +Workday integration with the ESS IT Agent isn't supported in this release. +Please contact your administrator. + +**End message.** + +Stop immediately. Do not create DA Workday state, run a Workday package +checkpoint, install a package, or enter any DA Workday lifecycle step. + +**ESS DA HR is supported.** Continue only when the active agent is +`msdyn_copilotforemployeeselfservicedahr`, or the selected DA inventory +unambiguously contains only `da.esshr`. Do not run `WD-PKG-001` or the CEA +lifecycle: DA packages share some Workday connection-reference names with +CEA, so that checkpoint is not an architecture discriminator. -**If any `selected_products` entry starts with `da.`**, use only the DA path. -Do not run `WD-PKG-001` or the CEA lifecycle: DA packages share some Workday -connection-reference names with CEA, so that checkpoint is not an -architecture discriminator. +If both DA HR and DA IT are installed but the active agent cannot be resolved, +stop and ask the maker to select the ESS HR Agent before running +`/connect workday` again. Never guess the vertical. Read `src/skills/setup/workday-da/SKILL.md` and follow it. That skill runs -`WD-DA-PKG-001`, installs any missing DA Workday child package, and resumes -the DA Entra and tenant checklist from live state. +`WD-DA-PKG-001`, installs or verifies the DA HR Workday child package, and +resumes the DA HR checklist through Entra, tenant, Power Platform connection, +bot-to-flow authorization, topic selection, and signed-in runtime validation. ### CEA agent -**Otherwise, only `cea.*` entries are present.** +Enter this branch when the resolved active agent is CEA, or when no active +agent is resolvable and every selected product is `cea.*`. If `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` already exists, read `src/skills/connect/workday/SKILL.md` and follow it. It diff --git a/solutions/ess-maker-skills/src/skills/connect/steps.md b/solutions/ess-maker-skills/src/skills/connect/steps.md index bb235331e..b8f1c6184 100644 --- a/solutions/ess-maker-skills/src/skills/connect/steps.md +++ b/solutions/ess-maker-skills/src/skills/connect/steps.md @@ -6,6 +6,7 @@ own steps.md and config.json under its subfolder: - `servicenow/steps.md` + `servicenow/config.json` - Workday routes by architecture: CEA uses `connect/workday/` when an extension already exists or `src/skills/setup/SKILL.md` for full setup; - DA uses `src/skills/setup/workday-da/SKILL.md`. + DA HR uses `src/skills/setup/workday-da/SKILL.md`. DA IT is not supported + for Workday in this release and does not enter a Workday lifecycle. See SKILL.md for routing logic. 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 d84b2059c..de28d9fc1 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 @@ -5,8 +5,8 @@ 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. -This router sequences the four Workday connect steps for the **Declarative Agent -(DA)** flavor of Employee Self-Service, using the master checklist as a +This router sequences the five Workday connect steps for the **ESS Declarative +Agent HR** flavor, using the master checklist as a **resume-aware spine**: it renders the working checklist on first run, resumes at the first unverified step, and dispatches to the owning step's playbook. It **never** advances past a `MANUAL` / attestation row on a flightcheck pass alone — @@ -17,6 +17,21 @@ This skill assumes the DA Employee Self-Service base agent itself is already installed — that's owned by `/setup`, not by this skill. DA-1 checks for it and sends you to `/setup` first if it isn't there yet. +This release does not support Workday for the ESS DA IT Agent. Before creating +the working checklist or reading provider state, resolve the active agent from +`.local/config.json`. If it is not the ESS DA HR Agent, show: + +**Message:** + +Workday integration with the ESS IT Agent isn't supported in this release. +Please contact your administrator. + +**End message.** + +Stop immediately without creating or updating any Workday state. This guard is +required even though the `/connect workday` router performs the same check, +because this file must remain safe if invoked directly. + --- ## Handling Workday credentials — never put secrets in chat @@ -45,16 +60,44 @@ ID, App ID URI) are safe to capture in chat — see ## Start -1. **Working copy.** If `.local/setup/workday-da/tasks.md` does not exist, render +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. + + **Message:** + + Here's the plan for connecting Workday to your ESS DA 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 ESS DA HR Workday package | 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. + + **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. -2. **Resume point.** Read `setupStatus` in `.local/connect/workday-da/config.json` +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"`. -3. **Show the checklist, then find where to resume.** Determine each item's state +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 @@ -84,21 +127,31 @@ ID, App ID URI) are safe to capture in chat — see - {m} Activate the Workday authentication policy - {m} Match the signing certificate - **4. Review your Workday configuration** - - {m} Review your Workday configuration + **4. Power Platform and agent integration** + - {m} Connect the Workday account + - {m} Connect Microsoft Dataverse + - {m} Share the Workday connection parameters + - {m} Bind the extension connections + - {m} Turn on the Workday cloud flows + - {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`}. **End message.** - Then walk the items in Step order (DA1.1, DA2.1 … DA4.1 — these IDs are + 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. -4. If **every** item is `done`, show the **All done** message and stop. +5. If **every** item is `done`, show the **All done** message and stop. --- @@ -157,17 +210,25 @@ incomplete row. When it returns, go back to **Start** to resume at the next unverified row. -### DA4.1 — Review your Workday configuration (DA-4) +### DA4.1 through DA4.8 — Configure Power Platform and agent integration (DA-4) + +Read `src/skills/setup/workday-da/configure-power-platform.md` and follow it. +That playbook configures or guides the Workday OAuthUser and Dataverse +connections, parameter sharing, stale-connection recovery, connection binding, +cloud-flow state, partner-script authorization, DA V2 employee context, topic +selection, and firewall allowlisting. It updates rows **DA4.1**–**DA4.8** +through the shared checklist-updater. Manual and attestation rows require +explicit evidence; DA4.6 is programmatic and passes only when the checked-in +authorization script verifies every target workflow. + +When it returns, go back to **Start** to resume at DA5.1. + +### DA5.1 — Validate Workday readiness (DA-5) Read `src/skills/setup/workday-da/verify-connection.md` and follow it. That -playbook re-runs `WD-DA-PKG-001` to reconfirm the extension package, summarizes -the Entra and tenant configuration recorded in -`.local/connect/workday-da/config.json`, and clearly states what this skill -does — and does not yet — verify about the live connection (see the "Deferred -to a follow-up" note in `tasks.md`), pointing to a full FlightCheck run for -anything beyond that. It updates row **DA4.1** through the shared -checklist-updater (`prog` gate — completes only when the live package recheck -passes). +playbook re-runs `WD-DA-PKG-001`, summarizes all setup areas, and requires a +successful signed-in employee Workday scenario. It updates **DA5.1** only after +runtime evidence is captured and sets provider `status` to `"ready"`. When it returns, go back to **Start** — every row should now be `done`. @@ -175,8 +236,8 @@ When it returns, go back to **Start** — every row should now be `done`. **Message:** -Your Workday installation and configuration checklist is complete. The final -review explains the remaining live connection and agent-path checks. Type -`/menu` to see what you can do next. +Your ESS DA HR Agent is connected to Workday and the signed-in employee path +has been validated in this environment. Type `/menu` to see what you can do +next. **End message.** 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 new file mode 100644 index 000000000..3fef485ee --- /dev/null +++ b/solutions/ess-maker-skills/src/skills/setup/workday-da/configure-power-platform.md @@ -0,0 +1,223 @@ + +# 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**. + +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. + +The two required connection references are: + +| Connection | Logical name | +| --- | --- | +| Workday OAuthUser | `new_sharedworkdaysoap_ff0df` | +| Microsoft Dataverse | `msviess_sharedcommondataserviceforapps_92b66` | + +Never reuse Dev connection IDs, bot IDs, or workflow IDs in another +environment. + +--- + +## DA4.0 — Determine fresh setup or simplified-setup upgrade + +Ask whether this environment is a fresh simplified setup or an upgrade from a +legacy ISU/RaaS Workday installation. + +- **Fresh setup** — continue normally. +- **Upgrade** — explain that updating the Workday package can make its + connection stale. Reuse and update the existing Workday API client where + appropriate, add the required functional areas and Workday-owned scope, + reconnect the Power Platform connection, and move to the package's V2 + signed-in-user context. Do not decommission the legacy ISU, security groups, + RaaS reports, or credentials until the simplified path has passed in every + environment and has been observed for an agreed business cycle. + +Persist `installPath: "simplified"` and, for an upgrade, also persist +`migrationSource: "legacy-isu-raas"`. + +## DA4.1 — Connect the Workday OAuthUser reference + +**Message:** + +Now we'll create or reconnect the Workday connection used on behalf of each +signed-in employee. + +In the Power Apps maker portal, select this environment and open the installed +Workday solution. Configure the **OAuthUser** connection reference using a +Workday connection with **Microsoft Entra ID Integrated** authentication. + +Use the values captured earlier: + +- 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`. + +Even if the page shows every reference as connected after the first connection, +open and configure each reference separately. + +**End message.** + +Ask the maker to confirm that OAuthUser is connected with the values above. +Record DA4.1 with `GATE="manual"`, `ACK=true`, and evidence describing the +connection name and environment. If it is not connected, leave the row +`in-progress`. + +## DA4.2 — Connect the Dataverse reference + +**Message:** + +In the same Workday solution, configure the **Microsoft Dataverse** connection +reference with an active connection owned by your maker account. Confirm it +targets this environment, not a Dev connection copied from another environment. + +**End message.** + +Ask for confirmation and record DA4.2 as a manual row with the connection name +and environment in the evidence. + +## DA4.3 — Share parameters and repair stale connections + +**Message:** + +Open the Workday connection's **Connection parameters** page and turn on +**Allow permission to share parameters**, then save. + +If the parameter values appear empty, turn the setting off and save, turn it +back on and save again, then confirm the REST, SOAP, token, client, and resource +values are still populated. + +If the connection is **Stale**, **Needs attention**, or no longer connected +after a package update or parameter change, reconnect it now. Users may also be +asked to authorize again on their next Workday request. + +**End message.** + +Require explicit confirmation that parameter sharing is enabled, the fields +remain populated, and the connection is connected. Record DA4.3 as manual. + +## DA4.4 — Confirm connection binding + +Guide the maker through the Workday solution's connection-reference and +connection-parameter configuration. Confirm: + +1. OAuthUser is bound to the Workday connection created in DA4.1. +2. Microsoft Dataverse is bound to the Dataverse connection from DA4.2. +3. The required `connectionparametersetconfig` values are populated for the + target environment. +4. No reference points to a connection from a different environment or user. + +The authorization script does not perform this step. Require explicit +confirmation and record DA4.4 as manual. + +## DA4.5 — Turn on the Workday cloud flows + +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. + +Otherwise show: + +**Message:** + +Open **Power Apps → Solutions → Workday → Cloud flows**. Turn on every flow used +by the ESS DA HR Agent, then confirm they all show **On**. Do not enable unrelated +flows from other solutions. + +**End message.** + +Record DA4.5 only after the maker confirms the complete DA Workday flow set is +on. + +## DA4.6 — Authorize the DA to use the Workday flows + +Use the checked-in authorization script: + +`scripts/alm/Enable-CosmosDAFlowAuthorization.ps1` + +Execute this PowerShell file directly. Do not translate, regenerate, or replace +it with Python. PowerShell 7 is preferred; Windows PowerShell 5.1 is also +supported by the script syntax. + +Resolve parameters instead of asking the maker to paste GUIDs: + +- `OrgUrl`: `.local/config.json` → `dataverseEndpoint`. +- `BotId`: active ESS DA HR agent → `agent.botId`. +- `WorkflowId[]`: the target Workday workflow IDs referenced by the active + agent's Workday topics. Resolve topic `flowId` values to Dataverse + `workflowid` values and exclude unrelated flows. +- `TeamName`: a deterministic name containing the agent and environment. + +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. + +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`. + +On a brand-new environment, the unchanged script's `-WhatIf` run may exit `1` +after showing the correct `would create` / `would share` plan. This happens +because its final verification checks for records that `-WhatIf` intentionally +did not write. Treat that preview as acceptable only when the target values are +correct and every `[FAIL]` is solely an expected absence corresponding to a +listed preview operation. Authentication, permission, lookup, missing-flow, +wrong-target, or unexpected-existing-record errors remain blocking. Do not +apply based on an ambiguous preview. + +DA4.6 passes only when the script exits with code `0`, ends with +`Dataverse authorization is in place.`, returns one access team for the target +bot, and confirms `WriteAccess` for every supplied workflow. On any failure, +leave the row blocked and show the script error. Do not replace this with an +attestation. + +After successful apply verification, update DA4.6 through +[`shared/checklist-updater.md`](shared/checklist-updater.md) with +`STEP_ID="DA4.6"`, `GATE="prog"`, and +`CHECKPOINT_RESULT="PASSED"`. Record the target environment, bot, workflow +display names, script exit code, and verification summary as evidence. On an +apply or verification failure, update it with `CHECKPOINT_RESULT="FAILED"` so +the row becomes blocked. + +## DA4.7 — Configure employee context and selected topics + +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. + +Present the available Workday topics and let the maker choose which scenarios +to enable. Preview the exact topic changes and obtain approval before mutating +the agent. Confirm: + +- the DA-equivalent V2 user-context component is enabled and wired; +- selected topics are enabled; +- unselected topics remain disabled; +- no legacy ISU/RaaS user-context component is selected for the simplified + path. + +If the package does not expose enough metadata to make this safe, provide the +equivalent Copilot Studio steps and record DA4.7 as manual after confirmation. + +## DA4.8 — Record firewall allowlisting + +**Message:** + +Your InfoSec/IT team must allow outbound access from the Power Platform Workday +managed connectors to these Workday hosts: + +- REST: `{restBaseUrl host}` +- SOAP: `{soapBaseUrl host}` + +Has that allowlisting been put in place for this environment? + +**End message.** + +This is an attestation, not a local connectivity test. Record DA4.8 only after +explicit acknowledgement and captured evidence. + +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 06c1de04c..7e6e95197 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 @@ -5,11 +5,11 @@ 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. -This step completes **DA1.1** on the Workday connect checklist. It confirms your -DA Employee Self-Service base agent is installed, then installs the Workday +This step completes **DA1.1** on the Workday connect checklist. It confirms the +DA Employee Self-Service HR base agent is installed, then installs the Workday extension package against it — attempting an automated install first and falling back to guided manual steps only if this tenant's Marketplace catalog -doesn't support it. +doesn't support it. The router must stop DA IT agents before this file is read. --- @@ -22,23 +22,16 @@ exists, and whether Workday is already installed against it: python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 ``` -Read the checkpoint result from `workspace/flightcheck/results.json`. Build -`TARGET_VERTICALS` from the live `Detected DA base agent editions` value: +Read the checkpoint result from `workspace/flightcheck/results.json`. The only +supported target is `hr`. An IT base agent or IT Workday package in the same +environment is outside this lifecycle and must not affect DA1.1. -- `HR` → `hr` -- `IT` → `it` - -An environment may contain both HR and IT. Treat each target vertical -independently; DA1.1 completes only when every target has its corresponding -Workday child package. Do not derive this list from `selected_products`; that -records setup intent and can differ from the packages currently installed in -the environment. - -- **`PASSED`** → every required Workday extension package is already installed. - Show the result, record `verticals` (the `TARGET_VERTICALS` array) into +- **`PASSED`** → the required HR Workday extension package is already installed. + Show the result, record `verticals: ["hr"]` into `.local/connect/workday-da/config.json`, and go to **record DA1.1** below. -- **`FAILED`** with "No DA Employee Self-Service base agent … was found" → - your DA base agent isn't installed yet. Stop here — this skill doesn't +- **`FAILED`** with "No ESS DA HR agent was found in this environment" or + "An ESS DA IT agent is installed, but no ESS DA HR agent was found" → the + supported HR base agent isn't installed. Stop here — this skill doesn't install the base agent. **Message:** @@ -52,8 +45,10 @@ the environment. Halt this skill entirely — do not proceed to DA-2 or DA-3. - **`FAILED`** with "The Workday extension package is not installed for the - {edition} edition" → the base agent is present but Workday isn't installed + ESS DA HR agent" → the HR base agent is present but Workday isn't installed yet. 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`** (Dataverse call failed, e.g. permissions or a transient error) → show the result verbatim and stop; ask the user to resolve the underlying issue (commonly a missing Dataverse role) and re-run this step. @@ -63,18 +58,17 @@ the environment. ## P1.1 — Attempt an automated install ``` -python scripts/install_workday_da_extension.py --url "{ENVIRONMENT_URL}" --vertical "{vertical}" +python scripts/install_workday_da_extension.py --url "{ENVIRONMENT_URL}" --vertical "hr" ``` -Run the command once for each target vertical that the checkpoint reported -missing. `ENVIRONMENT_URL` is the Dataverse endpoint from `.local/config.json`. +Run the command once. `ENVIRONMENT_URL` is the Dataverse endpoint from +`.local/config.json`. Parse the script's JSON marker line: -- **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → that vertical installed (or was - already installed and the script confirmed it). Continue with the next - missing vertical. After all installs, re-run `--checkpoint WD-DA-PKG-001`; - proceed only when it reports `PASSED`. +- **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → the HR package installed (or was + already installed and the script confirmed it). Re-run + `--checkpoint WD-DA-PKG-001`; proceed only when it reports `PASSED`. - **`WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:`** → this tenant's Marketplace catalog doesn't list the Workday extension package for the DA agent, so it can't be installed automatically here. This is expected in some tenants — @@ -108,9 +102,8 @@ installed the base Employee Self-Service agent: 1. Open the [Microsoft 365 admin center](https://admin.microsoft.com) (or AppSource, if that's where you installed the base agent). -2. Find the **Workday extension for Employee Self-Service** package for each - missing edition: **{missing editions}**. -3. Deploy each missing package to this environment. +2. Find the **Workday extension for Employee Self-Service HR** package. +3. Deploy the package to this environment. 4. Wait for the deployment to finish, then tell me it's done and I'll verify it. @@ -125,17 +118,15 @@ until it passes. While it is not yet passing, keep DA1.1 `in-progress`. When `WD-DA-PKG-001` is `PASSED`: -1. Merge `verticals` (array containing every installed target: `hr`, `it`, or - both) into `.local/connect/workday-da/config.json` (round-trip merge — - never drop other keys). For backward compatibility, also write `vertical` - only when exactly one target is present; remove a stale singular - `vertical` when both are present. +1. Merge `verticals: ["hr"]` and `vertical: "hr"` into + `.local/connect/workday-da/config.json` (round-trip merge — never drop other + keys). Remove any stale `it` entry written by a pre-release version. 2. Call [`shared/checklist-updater.md`](./shared/checklist-updater.md) with `STEP_ID = "DA1.1"`, `CHECKPOINT_RESULT = "PASSED"`, `GATE = "prog"`. **Message:** -The Workday extension package is installed for **{installed editions}**. +The Workday extension package is installed for the **ESS HR Agent**. **End message.** 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 9f009bb8b..8d37e4a81 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 @@ -8,8 +8,9 @@ cite this file so they agree on field names, owners, and types. **Canonical data file:** `.local/connect/workday-da/config.json` 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 four steps -(DA-1 install, DA-2 Entra, DA-3 tenant, DA-4 verify) instead of CEA's six. +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, +DA-5 runtime validation). --- @@ -48,10 +49,11 @@ read by later steps. Unknown/absent fields are treated as `null`. | `soapBaseUrl` | string | DA-3 | SOAP base (`https://{services-host}/ccx/service`). | | `domainName` | string | DA-3 | Workday domain name, when discovered. | | `tenantId` | string | DA-2 | **Entra** tenant ID (GUID) — set during Entra setup. | -| `installPath` | string | DA-3 | `"simplified"`. | -| `status` | string | all | `"in-progress"` \| `"configured"`. `"configured"` means the package, Entra, and Workday tenant checklist is complete; it does not claim live DA connection readiness. | -| `verticals` | array[string] | DA-1 | Every DA base-agent edition with its required Workday child installed: `["hr"]`, `["it"]`, or `["hr", "it"]`. | -| `vertical` | string | DA-1 | Backward-compatible singular alias, written only when `verticals` has exactly one item. | +| `installPath` | string | DA-3/DA-4 | `"simplified"`. | +| `migrationSource` | string | DA-4 | `"legacy-isu-raas"` when upgrading an existing legacy setup; absent for a fresh simplified setup. | +| `status` | string | all | `"in-progress"` \| `"configured"` \| `"ready"`. `"configured"` means setup values are recorded but runtime is not proven. Only DA-5 sets `"ready"` after a signed-in Workday scenario succeeds. | +| `verticals` | array[string] | DA-1 | Always `["hr"]` for this release. ESS DA IT is not supported by `/connect workday`. | +| `vertical` | string | DA-1 | Always `"hr"` for this release. | ### Entra app + OAuth client (owned by DA-2 / DA-3) @@ -68,7 +70,7 @@ read by later steps. Unknown/absent fields are treated as `null`. ### Per-step status fields (owned by each step via the checklist-updater) Each step records its own checkpoint outcomes under a `setupStatus` object, -keyed by **Step ID** (`DA1.1` … `DA4.1`) from the DA master checklist. This is +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 same data. @@ -94,19 +96,13 @@ same data. --- -## Deferred (not tracked yet) +## Power Platform integration state -The DA skill in this iteration does not track the following CEA-equivalent -fields — they are out of scope until a follow-up extends the connection -verification beyond installation: - -- `soapBaseUrl`/`restBaseUrl` binding verification against the live - connection references (CEA's `WD-CONN-AUTH-001`/`WD-REST-001`/`WD-NET-001` - equivalents) — DA-4 reports these as guided manual checks, not - programmatic ones, until DA-scoped checkpoints exist for the DA extension - package's connection references. -- `ootbTopics` — ready-made Workday topics. Topic installation for DA agents - is a separate, later piece of work; this skill does not offer it. +DA-4 records manual evidence for connection references, parameter sharing, +binding, flow state, selected topics, and firewall allowlisting until reliable +DA-scoped APIs are available. It must not reuse CEA checkpoints as proof. +DA4.6 is the exception: its programmatic evidence comes from the checked-in +authorization script. --- 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 bd3de9d1e..c36e2d968 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 @@ -1,7 +1,7 @@ # Workday Connect (DA) — Checklist (template) -The single, trackable checklist spanning the four Workday connect steps for the +The single, trackable checklist spanning the five Workday connect steps for the **Declarative Agent (DA)** flavor of Employee Self-Service. 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** @@ -50,7 +50,7 @@ express; all items start `pending`. ### 1. Workday extension package -- [ ] **Install the Workday extension package** — Add the Workday extension package to your DA agent so it can talk to Workday. If your DA base agent isn't installed yet, this step sends you to `/setup` first. +- [ ] **Install the Workday extension package** — Add the Workday extension package to your ESS DA HR agent so it can talk to Workday. If the HR base agent isn't installed yet, this step sends you to `/setup` first. ### 2. Workday single sign-on (Entra) @@ -81,22 +81,34 @@ express; all items start `pending`. - [ ] **Match the signing certificate** — Confirm the Workday-side signing certificate matches the one in Entra (validity dates, or an externally-computed SHA-1 — Workday shows no thumbprint). -### 4. Review your Workday configuration - -- [ ] **Review your Workday configuration** — Confirm the extension package, single sign-on, and tenant configuration are all in place, and see which live connection checks remain before your agent can use Workday. - +### 4. Power Platform and agent integration + +- [ ] **Connect the Workday account** — Create or reconnect the Workday OAuthUser connection with Microsoft Entra ID Integrated sign-in and the captured Workday endpoints. + +- [ ] **Connect Microsoft Dataverse** — Bind the Workday extension's Dataverse connection reference to an active connection owned by the maker. + +- [ ] **Share the Workday connection parameters** — Allow the extension to share its connection parameters for on-behalf-of authentication, and repair any stale connection after package or parameter changes. + +- [ ] **Bind the extension connections** — Confirm the Workday and Dataverse references and required connection parameter configuration point to this environment's connections. + +- [ ] **Turn on the Workday cloud flows** — Confirm every Workday flow used by the ESS DA HR Agent is enabled. + +- [ ] **Authorize the agent to use the Workday flows** — Preview and apply the delegated authorization and workflow sharing required by the ESS DA HR Agent. + +- [ ] **Configure employee context and topics** — Use the DA 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. + + +### 5. Validate Workday readiness + +- [ ] **Validate a signed-in Workday scenario** — Run a Workday topic as a signed-in employee and confirm the agent returns real data before marking the environment ready. + > An item backed by an **attest** or **manual** gate is **never** auto-completed > by its checkpoint — it requires an explicit user acknowledgement plus > captured evidence (see [`shared/checklist-updater.md`](shared/checklist-updater.md)). -## Deferred to a follow-up - -This checklist deliberately stops at "the extension package is installed and -your Entra/tenant configuration is in place." It does not yet include DA -checkpoints for binding the Workday connection references (account sign-in, -Dataverse connection, REST address, cloud flows, firewall allowlisting) the -way the CEA `setup/workday/tasks.md` skills 5.2–5.8 do — those checks are keyed -off connection-reference and cloud-flow internals specific to the CEA package -today, and DA-scoped equivalents don't exist yet. DA-4 tells you this -explicitly and points you to the full FlightCheck report so nothing is hidden. +DA-scoped APIs are not available for every Power Platform surface. Those rows +remain manual or attested rather than being falsely completed by CEA-specific +checks. The final row requires runtime evidence from a signed-in user. 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 f53844120..3d054f110 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 @@ -1,11 +1,10 @@ -# DA-4 — Review Your Workday Configuration +# DA-5 — Validate Workday Readiness -Role: **Environment Maker**. This step reviews everything the earlier steps -verified, re-confirms the extension package is still installed, and gives an -honest summary of what this skill does — and does not yet — check about the -live Workday connection. It owns master-checklist row **DA4.1** (a -programmatic row that completes only after the package recheck passes). +Role: **Environment Maker** with a signed-in Workday test user. This step +re-confirms the extension package, reviews every setup area, and requires a +real Workday scenario before the environment is marked ready. It owns +master-checklist row **DA5.1**. 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 @@ -13,7 +12,7 @@ files you are reading. --- -## DA4.1 — Review your Workday configuration +## DA5.1 — Validate a signed-in Workday scenario **Re-confirm the extension package.** @@ -33,7 +32,7 @@ DA1.1 state: it becomes `in-progress`. Tell the user the package must be restored or reverified, then return to the -orchestrator. Do not complete DA4.1. +orchestrator. Do not complete DA5.1. **Summarize the Entra and tenant configuration recorded so far.** Read `.local/connect/workday-da/config.json` and render what's known: @@ -47,44 +46,42 @@ Here's where your Workday connection stands: | Workday extension package | {✅/❌ from WD-DA-PKG-001} | | Workday single sign-on (Entra) | {✅ if DA2.1–DA2.7 are all `done`, else "in progress"} | | Workday tenant configuration | {✅ if DA3.1–DA3.4 are all `done`, else "in progress"} | +| Power Platform and agent integration | {✅ if DA4.1–DA4.8 are all `done`, else "in progress"} | **End message.** -If any of DA1.1, DA2.1–DA2.7, or DA3.1–DA3.4 is not `done`, tell the user which -step to finish and stop here — do not present the connection as ready. - -**If everything above is done, be transparent about what this skill has — and -has not — verified.** This skill confirms the extension package is installed and -your Entra/tenant configuration is in place. It does not yet run DA-scoped -checks against the Workday extension package's live connection references (the -Workday account sign-in, the Dataverse connection, the REST address, the cloud -flows, or the firewall allowlist) — those checks exist for the CEA Employee -Self-Service agent today (`WD-CONN-AUTH-001`, `DV-CONN-001`, `WD-REST-001`, -`WD-FLOW-*`, `WD-NET-001`) but are not yet built for the DA extension package. +If any of DA1.1, DA2.1–DA2.7, DA3.1–DA3.4, or DA4.1–DA4.8 is not `done`, +tell the user which step to finish and stop here — do not present the +connection as ready. **Message:** -Your Workday extension package, single sign-on, and tenant configuration are all -in place. One thing to know: this skill doesn't yet automatically verify the -live connection inside the extension package itself — things like the account -sign-in, the REST address, or your firewall allowlist. To finish confirming your -agent can reach Workday: - -1. Open the [Power Apps maker portal](https://make.powerapps.com), select this - environment, and confirm the Workday connection shows as **Connected** - under **Connections**. -2. Try a Workday scenario with your agent (for example, checking a vacation - balance) and confirm it returns real data. -3. If it doesn't, run - `python scripts/flightcheck/cli.py --scope workdayda --connect-config ".local/connect/workday-da/config.json"` - for the DA Workday report, or reach out to your Workday administrator to - recheck the tenant configuration above. +The configuration checklist is complete. Now validate the actual employee +path: + +1. Publish the ESS DA 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 an unexpected generic or ISU sign-in. + +Did the scenario complete successfully? **End message.** -Update **DA4.1** via [`shared/checklist-updater.md`](shared/checklist-updater.md) -with `STEP_ID="DA4.1"`, `GATE="prog"`, -`CHECKPOINT_RESULT="PASSED"`. +On success, record the scenario, test user category (never credentials), time, +and result as evidence. Update **DA5.1** with `GATE="manual"`, `ACK=true`, set +the provider `status` to `"ready"`, and 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 +connection and retry with a fresh conversation or test user. --- diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py index bfd50535f..9543e3789 100644 --- a/tests/flightcheck/checks/test_workday_da.py +++ b/tests/flightcheck/checks/test_workday_da.py @@ -41,8 +41,7 @@ SOLN_FILTER = ( "uniquename eq 'msdyn_copilotforemployeeselfservicedahr' or " "uniquename eq 'msdyn_copilotforemployeeselfservicedait' or " - "uniquename eq 'msdyn_essdahrworkday' or " - "uniquename eq 'msdyn_essdaitworkday'" + "uniquename eq 'msdyn_essdahrworkday'" ) SOLUTION_ID = "22222222-2222-2222-2222-222222222222" @@ -128,7 +127,7 @@ def test_failed_when_no_da_base_agent(runner: _MinimalRunner) -> None: r = results[0] assert r.checkpoint_id == "WD-DA-PKG-001" assert r.status == "Failed" - assert "No DA Employee Self-Service base agent" in r.result + assert "No ESS DA HR agent" in r.result assert "/setup" in r.remediation @@ -147,7 +146,7 @@ def test_failed_when_hr_base_agent_present_but_workday_child_missing( @responses.activate -def test_failed_when_it_base_agent_present_but_workday_child_missing( +def test_failed_when_only_it_base_agent_is_present( runner: _MinimalRunner, ) -> None: _register_solutions(solutions=[ @@ -156,7 +155,8 @@ def test_failed_when_it_base_agent_present_but_workday_child_missing( r = _check_workday_da_package_installed(runner)[0] assert r.status == "Failed" - assert "IT" in r.result + assert "ESS DA IT agent is installed" in r.result + assert "not supported" in r.remediation @responses.activate @@ -175,23 +175,10 @@ def test_passed_when_hr_workday_child_present(runner: _MinimalRunner) -> None: @responses.activate -def test_passed_when_it_workday_child_present(runner: _MinimalRunner) -> None: - _register_solutions(solutions=[ - _solution_record("msdyn_copilotforemployeeselfservicedait"), - _solution_record("msdyn_essdaitworkday"), - ]) - - r = _check_workday_da_package_installed(runner)[0] - assert r.status == "Passed" - assert "msdyn_essdaitworkday" in r.result - assert "Detected DA base agent editions: IT" in r.result - - -@responses.activate -def test_failed_lists_already_installed_vertical_when_other_missing( +def test_it_agent_does_not_block_supported_hr_package( runner: _MinimalRunner, ) -> None: - """Both HR and IT base agents installed; only HR has its Workday child.""" + """An IT agent in the environment is outside the active HR lifecycle.""" _register_solutions(solutions=[ _solution_record("msdyn_copilotforemployeeselfservicedahr"), _solution_record("msdyn_copilotforemployeeselfservicedait"), @@ -199,11 +186,9 @@ def test_failed_lists_already_installed_vertical_when_other_missing( ]) r = _check_workday_da_package_installed(runner)[0] - assert r.status == "Failed" - assert "IT" in r.result - assert "Already installed" in r.result + assert r.status == "Passed" + assert "ESS DA HR agent detected" in r.result assert "msdyn_essdahrworkday" in r.result - assert "Detected DA base agent editions: HR, IT" in r.result @responses.activate diff --git a/tests/flightcheck/test_cli.py b/tests/flightcheck/test_cli.py index 3508aa24d..3263c1687 100644 --- a/tests/flightcheck/test_cli.py +++ b/tests/flightcheck/test_cli.py @@ -28,6 +28,12 @@ from flightcheck import cli +def test_workday_da_check_is_explicit_scope_only() -> None: + """An optional DA HR package must not fail unrelated full runs.""" + assert ("Workday DA", cli.run_workday_da_checks) in cli.SCOPE_MAP["workdayda"] + assert ("Workday DA", cli.run_workday_da_checks) not in cli.FULL_SCOPE + + class TestOpenReportInBrowser: """Tests for cli.open_report_in_browser.""" diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py index 0ff03263b..ea91b92aa 100644 --- a/tests/scripts/test_install_workday_da_extension.py +++ b/tests/scripts/test_install_workday_da_extension.py @@ -70,22 +70,22 @@ def test_not_listed_when_marketplace_catalog_has_no_match(_mock_discover_tenant) @patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_skips_install_when_already_installed(_mock_discover_tenant): +def test_skips_install_when_hr_extension_already_installed(_mock_discover_tenant): import install_workday_da_extension as m powerplatform = FakePowerPlatformClient( "tenant-123", - packages=[{"uniqueName": "msdyn_essdaitworkday", "state": "Installed"}], + packages=[{"uniqueName": "msdyn_essdahrworkday", "state": "Installed"}], ) schema = m.install_workday_da_extension( "https://org.crm.dynamics.com", - "it", + "hr", pp_admin_client_factory=FakePPAdminClient, powerplatform_client_factory=lambda _tenant: powerplatform, ) - assert schema == "msdyn_essdaitworkday" + assert schema == "msdyn_essdahrworkday" assert powerplatform.install_calls == [] @@ -121,7 +121,7 @@ def test_installs_and_polls_until_installed(_mock_discover_tenant): def test_times_out_and_reports_last_status(_mock_discover_tenant): import install_workday_da_extension as m - schema_name = "msdyn_essdaitworkday" + schema_name = "msdyn_essdahrworkday" powerplatform = FakePowerPlatformClient( "tenant-123", packages=[ @@ -139,7 +139,7 @@ def sleep(seconds): with pytest.raises(m.InstallationTimeoutError, match="10 minutes"): m.install_workday_da_extension( "https://org.crm.dynamics.com", - "it", + "hr", pp_admin_client_factory=FakePPAdminClient, powerplatform_client_factory=lambda _tenant: powerplatform, poll_interval_seconds=300, @@ -148,6 +148,21 @@ def sleep(seconds): ) +@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") +def test_rejects_unsupported_it_vertical(_mock_discover_tenant): + import install_workday_da_extension as m + + with pytest.raises(ValueError, match="ESS DA IT Agent is not supported"): + m.install_workday_da_extension( + "https://org.crm.dynamics.com", + "it", + pp_admin_client_factory=FakePPAdminClient, + powerplatform_client_factory=lambda _tenant: FakePowerPlatformClient( + "tenant-123", packages=[] + ), + ) + + @patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") def test_reports_install_permission_failure(_mock_discover_tenant): import install_workday_da_extension as m diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 062e513a7..263fa14cd 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -76,9 +76,71 @@ def test_connect_workday_branch_routes_by_architecture_and_install_state(self): ) assert "WD-DA-PKG-001" in text assert "WD-PKG-001" in text + assert "ESS IT Agent isn't supported" in text + assert "Stop immediately" in text + assert 'status: "ready"' in text + assert "DA setup row through `DA5.1`" in text + assert ( + "ServiceNow integration with an ESS Declarative Agent isn't supported" + in text + ) + assert "Do not create ServiceNow state" in text + assert "Route by the resolved active agent's architecture" in text + assert "active CEA agent" in text + assert "DA and CEA products are" in text + da_skill = ( + _SOLUTION / "src" / "skills" / "setup" / "workday-da" / "SKILL.md" + ).read_text(encoding="utf-8") + assert "ESS IT Agent isn't supported" in da_skill + assert "without creating or updating any Workday state" in da_skill + assert "src/skills/setup/workday-da/configure-power-platform.md" in da_skill + assert "DA4.1 through DA4.8" in da_skill + assert "DA5.1" in da_skill + assert "Show the readiness briefing" in da_skill + assert "Entra Application Administrator" in da_skill + assert "Workday Administrator" in da_skill + assert "Dataverse System Administrator" in da_skill + assert "InfoSec or network administrator" in da_skill + assert "Workday test employee" in da_skill + da_power_platform = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "configure-power-platform.md" + ) + assert da_power_platform.is_file() + da_power_platform_text = da_power_platform.read_text(encoding="utf-8") + assert "new_sharedworkdaysoap_ff0df" in da_power_platform_text + assert "msviess_sharedcommondataserviceforapps_92b66" in da_power_platform_text + assert "Allow permission to share parameters" in da_power_platform_text + assert "Enable-CosmosDAFlowAuthorization.ps1" in da_power_platform_text + assert "Do not translate, regenerate, or replace" in da_power_platform_text + assert "with Python" in da_power_platform_text + assert "brand-new environment" in da_power_platform_text + assert "would create" in da_power_platform_text + assert 'STEP_ID="DA4.6"' in da_power_platform_text + assert 'CHECKPOINT_RESULT="PASSED"' in da_power_platform_text + assert "connectionparametersetconfig" in da_power_platform_text + assert "signed-in-user context" in da_power_platform_text assert "connect/workday/step" not in text, ( "connect/step1.md must not reference the retired connect/workday monolith" ) + da_install = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "install-extension.md" + ).read_text(encoding="utf-8") + assert "No ESS DA HR agent was found in this environment" in da_install + assert ( + "The Workday extension package is not installed for the" + in da_install + ) + assert "Any other **`FAILED`** result" in da_install def test_setup_workday_command_removed(self): # The /setup-workday command was retired; /connect workday is the sole From f8ed32196f7399f769b0ca76b8ec523e6eea1c41 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 11:06:44 -0700 Subject: [PATCH 06/17] fix(connect): address Workday review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ess-maker-skills/scripts/alm/README.md | 34 +++++-- .../shared/lifecycle-contract-schema.md | 19 ++-- .../skills/connect/shared/lifecycle-runner.md | 35 +++++--- .../src/skills/connect/step1.md | 49 ++++++---- .../src/skills/connect/workday/contract.json | 3 +- .../src/skills/setup/workday-da/SKILL.md | 20 +++-- .../workday-da/configure-power-platform.md | 48 ++++++---- .../setup/workday-da/provision-entra-app.md | 27 ++++-- .../workday-da/shared/checklist-updater.md | 21 +++-- .../setup/workday-da/verify-connection.md | 6 +- tests/setup/test_setup_router.py | 90 ++++++++++++++++++- 11 files changed, 272 insertions(+), 80 deletions(-) diff --git a/solutions/ess-maker-skills/scripts/alm/README.md b/solutions/ess-maker-skills/scripts/alm/README.md index bbd46915c..9e36b5fa9 100644 --- a/solutions/ess-maker-skills/scripts/alm/README.md +++ b/solutions/ess-maker-skills/scripts/alm/README.md @@ -9,7 +9,7 @@ do not modify its implementation. ## PowerShell is the supported implementation -Run the checked-in `.ps1` directly. Do not port or replace the partner +Run the checked-in `.ps1` directly. Do not port or replace the authorization implementation with Python. ADK orchestration may resolve parameters, preview the operation, and invoke the script, but the authorization implementation remains in the unchanged PowerShell file. The repository already uses @@ -22,6 +22,25 @@ machine execution policy. If organizational policy prevents local scripts from running, use the customer-approved signed-script or pipeline process rather than weakening the machine-wide execution policy. +## Current source-version limitation + +Keep the checked-in script unchanged. This source version can safely verify and +reuse an existing delegated authorization and single linked Access team, and +can add missing workflow shares. Do not use it to create a missing delegated +authorization or team: + +- Dataverse create requests return `204 No Content` unless the request asks for + a representation, while this source version reads the new IDs from the + response body. +- If multiple linked teams exist, this source version prints `[FAIL]` but may + still return exit code `0`. + +Before execution, confirm exactly one MCSBot delegated authorization and one +linked Access team already exist for the target bot. If either is missing, or +more than one team is returned, stop the deployment and obtain the corrected +Microsoft-owned script version. Do not work around this by modifying the +checked-in file or converting it to another language. + This release supports the **ESS DA HR Agent** only. Do not run this procedure for the ESS DA IT Agent. @@ -132,19 +151,20 @@ Review that the output targets: ### New-environment `-WhatIf` behavior The authorization script performs its final live verification after the -preview. In a new environment where the delegated authorization, access team, -or workflow shares do not exist yet, `-WhatIf` intentionally does not create -them. The final verification therefore prints `[FAIL]` for those not-yet-created -records and exits with code `1`. +preview. If workflow shares do not exist yet, `-WhatIf` intentionally does not +create them. The final verification therefore prints `[FAIL]` for those +not-yet-created shares and exits with code `1`. For the preview only, this is expected when all of the following are true: -- each intended create/share operation is shown as `would create` or - `would share`; +- each intended share operation is shown as `would share`; - the target organization, bot, team type, and workflows are correct; - the only `[FAIL]` lines describe records that the preview intentionally did not create. +If the preview says it would create the delegated authorization or team, stop: +that is the unsupported source-version case described above. + Any authentication, lookup, permission, wrong-target, missing-workflow, unexpected existing-record, or Dataverse request error is a real preview failure and must be resolved before apply. The real apply run must still meet diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md index f0195dd97..dec863c5b 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-contract-schema.md @@ -30,16 +30,20 @@ reads a contract, runs the checkpoints it names, and renders results. ```json "detect": { "checkpoint": "WD-PKG-001", - "meansInstalled": "Passed" + "meansInstalled": "Passed", + "meansNotInstalled": "NotConfigured" } ``` - `checkpoint` — a FlightCheck checkpoint ID whose result tells the caller whether the external package/extension already exists in the environment. - `meansInstalled` — the `status` value (from `flightcheck/runner.Status`) - that counts as "installed." Any other status (including `Failed` or - `NotConfigured`) means "not installed" — the caller should not invoke this - lifecycle and should fall back to its own from-scratch setup flow. + that permits installed-package routing. Providers with multiple installed + flavors must also inspect the checkpoint result and select only a compatible + lifecycle. +- `meansNotInstalled` — the sole status that permits a from-scratch setup + route. `Failed`, `Warning`, `Skipped`, `Error`, and unknown statuses are + remediation/stop outcomes, not evidence that the package is absent. ### Phase fields @@ -107,7 +111,8 @@ acknowledgement is complete. Partial or unavailable evidence leaves the phase "discovery": { "status": "done", "lastVerifiedAt": "2026-09-15T14:03:10Z", - "checkpointResults": { "WD-PKG-001": "Passed", "DV-CONN-001": "Passed", "WD-CONN-012": "Passed" } + "checkpointResults": { "WD-PKG-001": "Passed", "DV-CONN-001": "Passed", "WD-CONN-012": "Passed" }, + "checkpointAcknowledgements": {} }, "agent-wiring": { "status": "pending", @@ -129,6 +134,10 @@ acknowledgement is complete. Partial or unavailable evidence leaves the phase - `phases.{id}.actionApplied` — mutating phases only; `true` once the action doc has been followed at least once (so a resume doesn't re-apply an idempotent-unsafe action; it re-verifies instead). +- `phases.{id}.checkpointAcknowledgements` — map keyed by checkpoint ID for + accepted `Manual`/`Warning` results. Each value records the acknowledged + status and UTC `acknowledgedAt`. A resume may reuse the acknowledgement only + when the live status still matches; a changed status requires a new decision. The `agentSlug` and state-file path are mandatory isolation boundaries. A provider may be connected to multiple agents in one workspace; no agent may diff --git a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md index 1486a0fbf..483e2e202 100644 --- a/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md +++ b/solutions/ess-maker-skills/src/skills/connect/shared/lifecycle-runner.md @@ -113,17 +113,23 @@ anything else: steps — reuse that exact mechanism here, silently, without re-showing the up-front plan). 2. If every checkpoint still resolves to a status allowed by that phase's - `completionStatuses`, update `lastVerifiedAt` and - leave the phase `done`. Do **not** re-render the U.0 table for a phase that - was already `done` and stays `done` on resume — only surface output for - phases that change state or that are not yet done. -3. If any checkpoint now resolves to `Failed`/`Error`, set that phase back to - `blocked`, and **every phase after it** back to `pending` (later phases may - have depended on this one still holding). Render the failure using L.4b's - rendering + blocked-message steps and stop — do not re-run L.4a's mutating - action just because a live re-check regressed (that would re-apply a - change without a fresh gate/rollback); a regression always needs a human - look, not an automatic retry. + `completionStatuses`, and every `Manual`/`Warning` result has a matching + persisted `checkpointAcknowledgements` entry for that checkpoint and + status, update `lastVerifiedAt` and leave the phase `done`. Do **not** + re-render the U.0 table for a phase that was already `done` and stays + `done` on resume — only surface output for phases that change state or that + are not yet done. +3. If any checkpoint resolves to a status **outside** that phase's + `completionStatuses`, **or** an allowed `Manual`/`Warning` result lacks a + persisted acknowledgement matching the checkpoint and current status, the + cached completion has regressed. This includes `Warning`, `Skipped`, + `NotConfigured`, and unacknowledged `Manual`/`Warning` results — not only + `Failed`/`Error`. Set the phase to `blocked` for `Failed`/`Error`, otherwise + set it to `in-progress`, and set **every phase after it** back to `pending` + (later phases may have depended on this one still holding). Persist the + current checkpoint results, render the result using L.4b, and stop. Do not + re-run L.4a's mutating action merely because a live re-check regressed; + mutation requires a fresh gate and rollback checkpoint. Once every previously-`done` phase is confirmed (or the loop stopped early on a regression), continue to L.3. @@ -195,8 +201,11 @@ Aggregate the phase's outcome using the phase's `completionStatuses` `Manual`/`Warning` results — ask the same style of yes/no confirmation `checklist-updater.md`'s U.2 uses when a result needs acknowledgement): set `phases.{id}.status = "done"`, `lastVerifiedAt` = now, record each - checkpoint's status in `checkpointResults`. Write the state file. Return to - L.3 to advance to the next phase. + checkpoint's status in `checkpointResults`. For each acknowledged + `Manual`/`Warning` result, also record + `checkpointAcknowledgements.{checkpointId} = {status, acknowledgedAt}`. + Remove an old acknowledgement when that checkpoint now returns a different + status. Write the state file. Return to L.3 to advance to the next phase. - **Any checkpoint `Failed`/`Error`:** set `phases.{id}.status = "blocked"`, record `checkpointResults`. Write the state file. diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index 48cfb683f..ddfb4ec62 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -287,6 +287,18 @@ Please contact your administrator. Stop immediately. Do not create DA Workday state, run a Workday package checkpoint, install a package, or enter any DA Workday lifecycle step. +**ESS DA Hub is not supported in this release.** If the active agent is the +ESS DA Hub, or the only selected DA product is `da.esshub`, show: + +**Message:** + +Workday integration with the ESS Hub Agent isn't supported in this release. +Please select the ESS HR Agent or contact your administrator. + +**End message.** + +Stop immediately without creating or updating Workday state. + **ESS DA HR is supported.** Continue only when the active agent is `msdyn_copilotforemployeeselfservicedahr`, or the selected DA inventory unambiguously contains only `da.esshr`. Do not run `WD-PKG-001` or the CEA @@ -307,27 +319,32 @@ bot-to-flow authorization, topic selection, and signed-in runtime validation. Enter this branch when the resolved active agent is CEA, or when no active agent is resolvable and every selected product is `cea.*`. -If `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` already -exists, read `src/skills/connect/workday/SKILL.md` and follow it. It -re-verifies the active agent live before reporting "connected." - -Otherwise, check whether a CEA Workday extension already exists in this -environment: +Check the current CEA Workday extension before honoring any existing lifecycle +state: ``` python scripts/flightcheck/cli.py --checkpoint WD-PKG-001 ``` -**If `Passed`:** a Workday extension is already installed in this -environment — this agent likely just needs to be wired to it, not a full -install. Read `src/skills/connect/workday/SKILL.md` and follow it; it shows -the user what it will check before doing anything, and only makes changes -once they confirm. - -**If anything other than `Passed`**, read `src/skills/setup/SKILL.md` and -follow it. This path provisions the Power Platform environment, installs the -ESS base agent, provisions the Entra app, configures the Workday tenant, -installs the extension pack, and verifies the connection. It is resume-aware. +Read the `WD-PKG-001` row from `workspace/flightcheck/results.json` and route +by both its status and detected flavor: + +- **`Passed` + simplified-install result** — the installed extension can use + the lightweight per-agent lifecycle. Whether or not + `.local/connect/workday/agents/{ACTIVE_AGENT_SLUG}/lifecycle.json` already + exists, read + `src/skills/connect/workday/SKILL.md` and follow it. +- **`Passed` + full / legacy result** — do not run the simplified V2 wiring + lifecycle. Read `src/skills/setup/SKILL.md` and resume the full/legacy + Workday setup path, which handles legacy user context explicitly. +- **`NotConfigured`** — no Workday package is installed. Read + `src/skills/setup/SKILL.md` and start the resume-aware setup path. +- **`Failed`** — a partial or broken install was detected. Show the checkpoint + remediation and stop; do not treat it as a fresh environment. +- **`Warning` / `Skipped` / `Error`**, or a `Passed` result whose flavor cannot + be determined — package detection is inconclusive. Show the result and stop + so the maker can remediate or retry. Never start setup from an inconclusive + package check. ### If the user said something else diff --git a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json index a3c60a007..a5b92924a 100644 --- a/solutions/ess-maker-skills/src/skills/connect/workday/contract.json +++ b/solutions/ess-maker-skills/src/skills/connect/workday/contract.json @@ -4,7 +4,8 @@ "connectConfig": ".local/connect/workday/config.json", "detect": { "checkpoint": "WD-PKG-001", - "meansInstalled": "Passed" + "meansInstalled": "Passed", + "meansNotInstalled": "NotConfigured" }, "phases": [ { 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 de28d9fc1..5a9fdf1b2 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 @@ -19,12 +19,19 @@ sends you to `/setup` first if it isn't there yet. This release does not support Workday for the ESS DA IT Agent. Before creating the working checklist or reading provider state, resolve the active agent from -`.local/config.json`. If it is not the ESS DA HR Agent, show: +`.local/config.json` and read `.local/setup/config.json` `selected_products`. +Continue when the active agent resolves to an ESS DA HR agent instance with a +stable agent slug and `botId`. If no active agent is set, continue only when the +installed-agent inventory resolves exactly one ESS DA HR agent instance with +those fields; select that instance for this run before creating state. +`selected_products = ["da.esshr"]` alone is not enough because it identifies a +product, not a deployed agent. If the target is IT, Hub, CEA, mixed, ambiguous, +incomplete, or unresolved, show: **Message:** -Workday integration with the ESS IT Agent isn't supported in this release. -Please contact your administrator. +This Workday setup supports the ESS DA HR Agent only. Select the ESS HR Agent, +or contact your administrator if it isn't available. **End message.** @@ -151,7 +158,10 @@ ID, App ID URI) are safe to capture in chat — see rehydrate in-memory state — follow the playbook's stated build order rather than jumping straight into it. -5. If **every** item is `done`, show the **All done** message and stop. +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, + treat DA5.1 as `in-progress` and dispatch to DA-5 to reconcile readiness; + never claim success from checklist state alone. --- @@ -215,7 +225,7 @@ When it returns, go back to **Start** to resume at the next unverified row. Read `src/skills/setup/workday-da/configure-power-platform.md` and follow it. That playbook configures or guides the Workday OAuthUser and Dataverse connections, parameter sharing, stale-connection recovery, connection binding, -cloud-flow state, partner-script authorization, DA V2 employee context, topic +cloud-flow state, checked-in script authorization, DA V2 employee context, topic selection, and firewall allowlisting. It updates rows **DA4.1**–**DA4.8** through the shared checklist-updater. Manual and attestation rows require explicit evidence; DA4.6 is programmatic and passes only when the checked-in 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 3fef485ee..312eb13e4 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 @@ -157,32 +157,50 @@ 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. +Before invoking the checked-in version, perform the same read-only +delegated-authorization and team lookups documented by the script: + +- exactly one MCSBot delegated authorization and one linked Access team already + exist for the bot → the script may verify/reuse them and add missing workflow + shares; +- no authorization or team exists → stop before apply. The checked-in source + version requires a Microsoft-owned update that requests Dataverse POST + representations before it can safely create and bind those records; +- more than one linked team exists → stop and require administrator + remediation. Do not rely on the script's exit code because this source + version can print `[FAIL]` for multiple teams without returning failure. + 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`. -On a brand-new environment, the unchanged script's `-WhatIf` run may exit `1` -after showing the correct `would create` / `would share` plan. This happens -because its final verification checks for records that `-WhatIf` intentionally -did not write. Treat that preview as acceptable only when the target values are -correct and every `[FAIL]` is solely an expected absence corresponding to a -listed preview operation. Authentication, permission, lookup, missing-flow, -wrong-target, or unexpected-existing-record errors remain blocking. Do not -apply based on an ambiguous preview. - -DA4.6 passes only when the script exits with code `0`, ends with +When the authorization and team already exist but workflow shares are missing, +the unchanged script's `-WhatIf` run may exit `1` after showing the correct +`would share` plan. This happens because its final verification checks for +shares that `-WhatIf` intentionally did not write. Treat that preview as +acceptable only when the target values are correct and every `[FAIL]` is solely +an expected missing share corresponding to a listed preview operation. A +`would create` authorization/team operation, authentication, permission, +lookup, missing-flow, wrong-target, or unexpected-existing-record error remains +blocking. Do not apply based on an ambiguous preview. + +DA4.6 passes only when the preflight found exactly one linked Access team, the +script exits with code `0`, ends with `Dataverse authorization is in place.`, returns one access team for the target -bot, and confirms `WriteAccess` for every supplied workflow. On any failure, +bot, contains no `[FAIL]` line, and confirms `WriteAccess` for every supplied +workflow. On any failure, leave the row blocked and show the script error. Do not replace this with an attestation. After successful apply verification, update DA4.6 through [`shared/checklist-updater.md`](shared/checklist-updater.md) with `STEP_ID="DA4.6"`, `GATE="prog"`, and -`CHECKPOINT_RESULT="PASSED"`. Record the target environment, bot, workflow -display names, script exit code, and verification summary as evidence. On an -apply or verification failure, update it with `CHECKPOINT_RESULT="FAILED"` so -the row becomes blocked. +`CHECKPOINT_RESULT="PASSED"`, `RESULT_SOURCE="external"`, and +`EXTERNAL_EVIDENCE` containing the target environment, bot, workflow display +names, script exit code, and verification summary. Render that summary instead +of reading `workspace/flightcheck/results.json`. On an apply or verification +failure, use `CHECKPOINT_RESULT="FAILED"`, `RESULT_SOURCE="external"`, and the +safe failure summary so the row becomes blocked. ## DA4.7 — Configure employee context and selected topics 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 c43822eda..44616e61e 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 @@ -128,6 +128,16 @@ Otherwise ask for the Workday URL with the `vscode_askQuestions` tool: If the host matches no known pattern, keep `WD_TENANT` / `WD_BASE_URL` and leave `WD_TOKEN_HOST` for DA-3 to resolve from the API-client token endpoint. +Before persisting or interpolating the tenant, require: + +- an `https` URL; +- a non-empty first path segment; +- `WD_TENANT` matches + `^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`. + +If any condition fails, reject the value and ask again. Never persist or place +an unvalidated path segment into an `az` command. + **Persist** to `.local/connect/workday-da/config.json` (merge — keep other keys, per [`shared/config-schema.md`](shared/config-schema.md)): `tenant` = `WD_TENANT`, `baseUrl` = `WD_BASE_URL`, and `tokenHost` = `WD_TOKEN_HOST` when @@ -222,14 +232,15 @@ az ad sp list --display-name "Workday" --query "[].{name:displayName, appId:appI ] ``` - Emit one option object per returned SAML app: set `label` to the app's - `displayName` and `description` to its SSO mode plus its first reply URL (a - human-meaningful disambiguator — do **not** put the full app/object GUID in - the description; append only the last 6 characters of the `appId` if two apps - are otherwise indistinguishable). Mark the app named exactly **`Workday (ESS - Copilot)`** as `recommended` when present — that is the app this kit - provisions. Then: - - **User picks an existing app** → map the chosen label back to that app and + Emit one option object per returned SAML app. Build a label-to-app mapping + before asking. If a display name is unique, use it as the label. If two or + more apps share a display name, make each **label itself** unique by + appending `· {last 6 characters of appId}`. Set `description` to its SSO + mode plus first reply URL; never include a full app/object GUID. Mark the + option for the kit-provisioned **`Workday (ESS Copilot)`** app as + `recommended` when unambiguous. Then: + - **User picks an existing app** → use the retained label-to-app mapping + (never a display-name search) to map the unique chosen label to that app and save its `appId` → `WD_ENTRA_APP_ID` and its `id` (the service-principal id) → `WD_ENTRA_SP_ID`, then resolve its **application** object id — the `az ad sp list` results carry the *service-principal* id, **not** the app 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 62e739e90..8206c8e7a 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 @@ -26,6 +26,12 @@ not narrate tool calls. `config-schema.md`). - `ACK` — *(manual/attest rows only)* `true` once the user has explicitly acknowledged the step and any evidence has been captured; otherwise `false`. +- `RESULT_SOURCE` — `"flightcheck"` (default) when `CHECKPOINT_RESULT` came + from the current FlightCheck results file, or `"external"` when a + programmatic operation produced its own structured evidence. +- `EXTERNAL_EVIDENCE` — required when `RESULT_SOURCE="external"`; a safe + summary proving the operation's target, outcome, and verification. Never + include credentials, tokens, or raw sensitive output. **Outputs:** - The matching checklist item in `.local/setup/workday-da/tasks.md` is updated @@ -77,9 +83,10 @@ convention), do not repeat it — proceed to U.1. The U.1–U.3 status update be runs afterwards (once any attestation is answered) and does **not** re-display the result. -- Skip this step when `CHECKPOINT_RESULT` is `null` (the checkpoint was not run - this pass — e.g. the row is only being moved to `in-progress`), or when - `workspace/flightcheck/results.json` does not exist. +- Skip this step when `RESULT_SOURCE="external"`; show `EXTERNAL_EVIDENCE` + using the calling playbook's operation-specific result instead. Also skip + when `CHECKPOINT_RESULT` is `null` (the checkpoint was not run this pass), + or when `workspace/flightcheck/results.json` does not exist. Read `workspace/flightcheck/results.json` — the run that led here wrote it. It has: @@ -117,8 +124,9 @@ Do this right after the U.0 table, before touching any state. A report** — for `MANUAL` checks the verification steps must appear **in chat**, not in a browser popup. This routine is what puts them there. -- Skip this step when `CHECKPOINT_RESULT` is `null`, or when - `workspace/flightcheck/results.json` does not exist. +- Skip this step when `RESULT_SOURCE="external"`; the calling playbook has + already shown `EXTERNAL_EVIDENCE`. Also skip when `CHECKPOINT_RESULT` is + `null`, or when `workspace/flightcheck/results.json` does not exist. Each entry in `results.json` carries the full text of what the operator must do — not just `description`/`status` but also the finding and the how-to: @@ -196,6 +204,9 @@ Notes: - For `prog` rows, `NEW_STATE` from the caller must be consistent with `CHECKPOINT_RESULT`; if they conflict, the checkpoint result wins (it's the objective signal). +- For a `prog` row with `RESULT_SOURCE="external"`, `PASSED` is valid only when + non-empty `EXTERNAL_EVIDENCE` is supplied. Otherwise treat the result as + `null` and leave the row `in-progress`. If the row is `manual`/`attest` and `ACK` is `false`, before leaving the row `in-progress` confirm the user actually saw the manual step. (Precondition: the 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 3d054f110..6bce2e8d8 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 @@ -72,8 +72,10 @@ Did the scenario complete successfully? **End message.** On success, record the scenario, test user category (never credentials), time, -and result as evidence. Update **DA5.1** with `GATE="manual"`, `ACK=true`, set -the provider `status` to `"ready"`, and return to the orchestrator. +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 failure, leave DA5.1 `in-progress`. Run `python scripts/flightcheck/cli.py --scope workdayda --connect-config ".local/connect/workday-da/config.json"` diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 263fa14cd..3b9bc4e7f 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -15,6 +15,7 @@ from __future__ import annotations +import json import re from pathlib import Path @@ -88,10 +89,15 @@ def test_connect_workday_branch_routes_by_architecture_and_install_state(self): assert "Route by the resolved active agent's architecture" in text assert "active CEA agent" in text assert "DA and CEA products are" in text + assert "ESS DA Hub is not supported" in text + assert "Passed` + simplified-install result" in text + assert "Passed` + full / legacy result" in text + assert "NotConfigured" in text + assert "do not treat it as a fresh environment" in text da_skill = ( _SOLUTION / "src" / "skills" / "setup" / "workday-da" / "SKILL.md" ).read_text(encoding="utf-8") - assert "ESS IT Agent isn't supported" in da_skill + assert "supports the ESS DA HR Agent only" in da_skill assert "without creating or updating any Workday state" in da_skill assert "src/skills/setup/workday-da/configure-power-platform.md" in da_skill assert "DA4.1 through DA4.8" in da_skill @@ -102,6 +108,10 @@ def test_connect_workday_branch_routes_by_architecture_and_install_state(self): assert "Dataverse System Administrator" in da_skill assert "InfoSec or network administrator" in da_skill assert "Workday test employee" in da_skill + assert 'provider `status` to be `"ready"`' in da_skill + assert "installed-agent inventory resolves exactly one" in da_skill + assert "stable agent slug and `botId`" in da_skill + assert '`selected_products = ["da.esshr"]` alone is' in da_skill da_power_platform = ( _SOLUTION / "src" @@ -118,10 +128,12 @@ def test_connect_workday_branch_routes_by_architecture_and_install_state(self): assert "Enable-CosmosDAFlowAuthorization.ps1" in da_power_platform_text assert "Do not translate, regenerate, or replace" in da_power_platform_text assert "with Python" in da_power_platform_text - assert "brand-new environment" in da_power_platform_text + assert "workflow shares are missing" in da_power_platform_text assert "would create" in da_power_platform_text assert 'STEP_ID="DA4.6"' in da_power_platform_text assert 'CHECKPOINT_RESULT="PASSED"' in da_power_platform_text + assert 'RESULT_SOURCE="external"' in da_power_platform_text + assert "EXTERNAL_EVIDENCE" in da_power_platform_text assert "connectionparametersetconfig" in da_power_platform_text assert "signed-in-user context" in da_power_platform_text assert "connect/workday/step" not in text, ( @@ -150,7 +162,11 @@ def test_setup_workday_command_removed(self): ) def test_connect_workday_provider_uses_contract_not_step_monolith(self): - assert (_WORKDAY_PROVIDER_DIR / "contract.json").is_file() + contract_path = _WORKDAY_PROVIDER_DIR / "contract.json" + assert contract_path.is_file() + contract = json.loads(contract_path.read_text(encoding="utf-8")) + assert contract["detect"]["meansInstalled"] == "Passed" + assert contract["detect"]["meansNotInstalled"] == "NotConfigured" assert (_WORKDAY_PROVIDER_DIR / "SKILL.md").is_file() assert not list(_WORKDAY_PROVIDER_DIR.glob("step*.md")), ( "the provider lifecycle must not restore the retired step-file monolith" @@ -164,6 +180,74 @@ def test_connect_workday_provider_uses_contract_not_step_monolith(self): "connect/SKILL.md must still route ServiceNow" ) + runner = ( + _SOLUTION + / "src" + / "skills" + / "connect" + / "shared" + / "lifecycle-runner.md" + ).read_text(encoding="utf-8") + assert "status **outside** that phase's" in runner + assert "set it to `in-progress`" in runner + assert "checkpointAcknowledgements" in runner + assert "persisted `checkpointAcknowledgements`" in runner + assert "allowed `Manual`/`Warning` result lacks" in runner + + lifecycle_schema = ( + _SOLUTION + / "src" + / "skills" + / "connect" + / "shared" + / "lifecycle-contract-schema.md" + ).read_text(encoding="utf-8") + assert "checkpointAcknowledgements" in lifecycle_schema + assert "live status still matches" in lifecycle_schema + + updater = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "shared" + / "checklist-updater.md" + ).read_text(encoding="utf-8") + assert 'RESULT_SOURCE` — `"flightcheck"`' in updater + assert 'RESULT_SOURCE="external"' in updater + assert "calling playbook has\n already shown `EXTERNAL_EVIDENCE`" in updater + + entra = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "provision-entra-app.md" + ).read_text(encoding="utf-8") + assert "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" in entra + assert "label-to-app mapping" in entra + + power_platform = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "configure-power-platform.md" + ).read_text(encoding="utf-8") + assert "exactly one MCSBot delegated authorization" in power_platform + assert "contains no `[FAIL]` line" in power_platform + assert "`would create` authorization/team operation" in power_platform + + authorization_readme = ( + _SOLUTION / "scripts" / "alm" / "README.md" + ).read_text(encoding="utf-8") + assert "Current source-version limitation" in authorization_readme + assert "exactly one MCSBot delegated authorization" in authorization_readme + assert "more than one team is returned" in authorization_readme + def test_router_dispatches_to_skill1_playbook(self): text = _ROUTER.read_text(encoding="utf-8") assert ( From c357559fb967fcd6ed335bfbb8be6c393514a9af Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 11:09:15 -0700 Subject: [PATCH 07/17] fix(connect): align Workday flow with MOS setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/install_workday_da_extension.py | 133 ++++- tests/setup/test_setup_router.py | 511 ++++-------------- 2 files changed, 226 insertions(+), 418 deletions(-) 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 47562016d..632396871 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -4,13 +4,12 @@ """Attempt an automated install of the DA Workday extension package. Used by ``src/skills/setup/workday-da/install-extension.md`` (step DA1.1). -Unlike ``install_ess_agent.py`` (the base ESS agent), the DA Workday -extension package has no entry in +The DA Workday extension package has no entry in ``src/reference/ess-agent-installation/config.json`` — there is no confirmed Marketplace catalog record for it. This script therefore does not assume the package is installable through the Marketplace application API; it only -*tries*, using the same mechanism ``install_ess_agent.py`` uses for the base -agent (``PowerPlatformClient.list_environment_application_packages`` / +*tries* through +``PowerPlatformClient.list_environment_application_packages`` / ``install_application_package``), and reports a distinct, honest outcome — ``not-listed`` — when the tenant's Marketplace catalog does not return the package at all. The calling skill step must treat ``not-listed`` as "hand off @@ -35,22 +34,118 @@ from flightcheck.powerplatform_client import PowerPlatformClient from flightcheck.pp_admin_client import PPAdminClient -# Reuse the base-agent installer's polling vocabulary and helpers rather than -# redefining them, so both scripts observe the Marketplace application API -# identically. -from install_ess_agent import ( # noqa: F401 (re-exported for callers/tests) - DEFAULT_TIMEOUT_SECONDS, - FAILED_STATES, - INSTALLED_STATES, - IN_PROGRESS_STATES, - InstallationTimeoutError, - _error_message, - _find_package, - _list_packages, - _wait_for_install, -) - WORKDAY_DA_CHILD_SCHEMAS = {"hr": _DA_HR_WORKDAY_CHILD_SCHEMA} +INSTALLED_STATES = {"Installed", "TemplateInstalled"} +IN_PROGRESS_STATES = { + "InstallRequested", + "Installing", + "InstallScheduled", + "InstallRetrying", +} +FAILED_STATES = {"InstallFailed"} +DEFAULT_TIMEOUT_SECONDS = 10 * 60 + + +class InstallationTimeoutError(RuntimeError): + """Raised when Power Platform does not finish installation in time.""" + + def __init__(self, unique_name: str, timeout_seconds: int): + self.unique_name = unique_name + self.timeout_seconds = timeout_seconds + super().__init__( + "Application installation did not finish within " + f"{timeout_seconds // 60} minutes." + ) + + +def _find_package(packages: list[dict], schema_name: str) -> dict | None: + """Find the entitled application whose unique name matches the schema.""" + target = schema_name.casefold() + for package in packages: + names = (package.get("uniqueName"), package.get("applicationName")) + if any( + isinstance(name, str) and name.casefold() == target + for name in names + ): + return package + return None + + +def _error_message(response: dict, default: str) -> str: + """Return a concise API error without dumping the full response.""" + error = ( + response.get("error") + or response.get("errorDetails") + or response.get("lastError") + or {} + ) + if isinstance(error, dict): + message = error.get("message") or error.get("errorName") + if message: + return str(message) + message = response.get("statusMessage") + return str(message) if message else default + + +def _list_packages( + client: PowerPlatformClient, environment_id: str +) -> list[dict]: + packages = client.list_environment_application_packages(environment_id) + if isinstance(packages, dict) and packages.get("_error"): + raise RuntimeError( + "Your account cannot read Marketplace applications for this " + "environment. Use a Power Platform or Dynamics 365 administrator " + "account." + ) + return packages + + +def _wait_for_install( + client: PowerPlatformClient, + environment_id: str, + unique_name: str, + *, + timeout_seconds: int, + poll_interval_seconds: int, + sleep, + clock, + status_callback, +) -> None: + """Poll the application-package collection until installation completes.""" + started_at = clock() + deadline = started_at + timeout_seconds + poll_number = 0 + + while True: + now = clock() + if now >= deadline: + break + poll_number += 1 + observed_status = "Unknown" + package = _find_package( + _list_packages(client, environment_id), + unique_name, + ) + if package: + state = package.get("state") + observed_status = state or "Unknown" + if state in INSTALLED_STATES: + return + if state in FAILED_STATES: + raise RuntimeError( + _error_message( + package, + f"Application installation ended with state {state}.", + ) + ) + status_callback( + "Installation status " + f"(poll {poll_number}, {int(now - started_at)}s elapsed): " + f"{observed_status}" + ) + sleep(poll_interval_seconds) + + raise InstallationTimeoutError(unique_name, timeout_seconds) class ExtensionNotListedError(RuntimeError): diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 3b9bc4e7f..6fc2eab5b 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -1,417 +1,130 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Structural consistency guard for the Workday setup orchestrator (router) and -the ``/connect workday`` entry point that reaches it. - -Pure-logic, no network (see ``tests/AGENTS.md`` — pure-logic helpers are exempt -from the cassette rule). The router ``src/skills/setup/SKILL.md`` is a -Message-block dispatcher reached via ``/connect workday`` (its Workday branch in -``connect/step1.md`` delegates here; the standalone ``/setup-workday`` command was -retired). If a routed file path drifts (renamed/typo'd playbook, moved template) -the orchestrator silently dead-ends at setup time. This test pins the router's -wiring so that drift is caught at CI time instead. -""" +"""Structural guards for Workday routing and the hybrid setup boundary.""" from __future__ import annotations import json -import re from pathlib import Path -# tests/setup/test_setup_router.py -> repo root is parents[2]. + _REPO_ROOT = Path(__file__).resolve().parents[2] _SOLUTION = _REPO_ROOT / "solutions" / "ess-maker-skills" -_ROUTER = _SOLUTION / "src" / "skills" / "setup" / "SKILL.md" +_BOUNDARY = _SOLUTION / "src" / "skills" / "setup" / "SKILL.md" _CONNECT_STEP1 = _SOLUTION / "src" / "skills" / "connect" / "step1.md" _CONNECT_SKILL = _SOLUTION / "src" / "skills" / "connect" / "SKILL.md" _OLD_PROMPT = _SOLUTION / ".github" / "prompts" / "setup-workday.prompt.md" _WORKDAY_PROVIDER_DIR = _SOLUTION / "src" / "skills" / "connect" / "workday" -_TEMPLATE = _SOLUTION / "src" / "skills" / "setup" / "workday" / "tasks.md" -_SKILL1 = ( - _SOLUTION / "src" / "skills" / "setup" / "workday" - / "provision-power-platform-environment.md" -) -_SKILL2 = ( - _SOLUTION / "src" / "skills" / "setup" / "workday" / "install-ess.md" -) -_SKILL3 = ( - _SOLUTION / "src" / "skills" / "setup" / "workday" - / "provision-workday-entra-app.md" -) -_SKILL4 = ( - _SOLUTION / "src" / "skills" / "setup" / "workday" - / "configure-workday-tenant.md" +_WORKDAY_PLAYBOOKS = ( + "provision-power-platform-environment.md", + "install-ess.md", + "provision-workday-entra-app.md", + "configure-workday-tenant.md", + "install-workday-extension-pack.md", + "install-workday-ootb-topics.md", + "create-new-topic.md", ) -_SKILL5 = ( - _SOLUTION / "src" / "skills" / "setup" / "workday" - / "install-workday-extension-pack.md" -) -_SKILL6 = ( - _SOLUTION / "src" / "skills" / "setup" / "workday" - / "create-new-topic.md" -) -_OOTB = ( - _SOLUTION / "src" / "skills" / "setup" / "workday" - / "install-workday-ootb-topics.md" -) - -# Backtick-quoted repo-relative markdown paths the router points at. -_PATH_RE = re.compile(r"`(src/skills/[^`]+?\.md)`") - - -class TestSetupRouter: - def test_router_exists(self): - assert _ROUTER.is_file(), f"missing setup router: {_ROUTER}" - - def test_connect_workday_branch_routes_by_architecture_and_install_state(self): - # DA routes to setup; an installed CEA extension routes to the - # provider lifecycle; a fresh CEA setup still routes to setup. - text = _CONNECT_STEP1.read_text(encoding="utf-8") - assert "src/skills/setup/SKILL.md" in text, ( - "connect/step1.md Workday branch must route to the setup orchestrator" - ) - assert "src/skills/connect/workday/SKILL.md" in text, ( - "connect/step1.md must route an installed CEA extension to the " - "provider lifecycle" - ) - assert "WD-DA-PKG-001" in text - assert "WD-PKG-001" in text - assert "ESS IT Agent isn't supported" in text - assert "Stop immediately" in text - assert 'status: "ready"' in text - assert "DA setup row through `DA5.1`" in text - assert ( - "ServiceNow integration with an ESS Declarative Agent isn't supported" - in text - ) - assert "Do not create ServiceNow state" in text - assert "Route by the resolved active agent's architecture" in text - assert "active CEA agent" in text - assert "DA and CEA products are" in text - assert "ESS DA Hub is not supported" in text - assert "Passed` + simplified-install result" in text - assert "Passed` + full / legacy result" in text - assert "NotConfigured" in text - assert "do not treat it as a fresh environment" in text - da_skill = ( - _SOLUTION / "src" / "skills" / "setup" / "workday-da" / "SKILL.md" - ).read_text(encoding="utf-8") - assert "supports the ESS DA HR Agent only" in da_skill - assert "without creating or updating any Workday state" in da_skill - assert "src/skills/setup/workday-da/configure-power-platform.md" in da_skill - assert "DA4.1 through DA4.8" in da_skill - assert "DA5.1" in da_skill - assert "Show the readiness briefing" in da_skill - assert "Entra Application Administrator" in da_skill - assert "Workday Administrator" in da_skill - assert "Dataverse System Administrator" in da_skill - assert "InfoSec or network administrator" in da_skill - assert "Workday test employee" in da_skill - assert 'provider `status` to be `"ready"`' in da_skill - assert "installed-agent inventory resolves exactly one" in da_skill - assert "stable agent slug and `botId`" in da_skill - assert '`selected_products = ["da.esshr"]` alone is' in da_skill - da_power_platform = ( - _SOLUTION - / "src" - / "skills" - / "setup" - / "workday-da" - / "configure-power-platform.md" - ) - assert da_power_platform.is_file() - da_power_platform_text = da_power_platform.read_text(encoding="utf-8") - assert "new_sharedworkdaysoap_ff0df" in da_power_platform_text - assert "msviess_sharedcommondataserviceforapps_92b66" in da_power_platform_text - assert "Allow permission to share parameters" in da_power_platform_text - assert "Enable-CosmosDAFlowAuthorization.ps1" in da_power_platform_text - assert "Do not translate, regenerate, or replace" in da_power_platform_text - assert "with Python" in da_power_platform_text - assert "workflow shares are missing" in da_power_platform_text - assert "would create" in da_power_platform_text - assert 'STEP_ID="DA4.6"' in da_power_platform_text - assert 'CHECKPOINT_RESULT="PASSED"' in da_power_platform_text - assert 'RESULT_SOURCE="external"' in da_power_platform_text - assert "EXTERNAL_EVIDENCE" in da_power_platform_text - assert "connectionparametersetconfig" in da_power_platform_text - assert "signed-in-user context" in da_power_platform_text - assert "connect/workday/step" not in text, ( - "connect/step1.md must not reference the retired connect/workday monolith" - ) - da_install = ( - _SOLUTION - / "src" - / "skills" - / "setup" - / "workday-da" - / "install-extension.md" - ).read_text(encoding="utf-8") - assert "No ESS DA HR agent was found in this environment" in da_install - assert ( - "The Workday extension package is not installed for the" - in da_install - ) - assert "Any other **`FAILED`** result" in da_install - - def test_setup_workday_command_removed(self): - # The /setup-workday command was retired; /connect workday is the sole - # entry point to the orchestrator. - assert not _OLD_PROMPT.exists(), ( - "the /setup-workday prompt must be removed (retired command)" - ) - - def test_connect_workday_provider_uses_contract_not_step_monolith(self): - contract_path = _WORKDAY_PROVIDER_DIR / "contract.json" - assert contract_path.is_file() - contract = json.loads(contract_path.read_text(encoding="utf-8")) - assert contract["detect"]["meansInstalled"] == "Passed" - assert contract["detect"]["meansNotInstalled"] == "NotConfigured" - assert (_WORKDAY_PROVIDER_DIR / "SKILL.md").is_file() - assert not list(_WORKDAY_PROVIDER_DIR.glob("step*.md")), ( - "the provider lifecycle must not restore the retired step-file monolith" - ) - skill = _CONNECT_SKILL.read_text(encoding="utf-8") - assert "connect/workday/step" not in skill, ( - "connect/SKILL.md must not reference the retired monolith step files" - ) - # ServiceNow routing must remain intact. - assert "src/skills/connect/servicenow/" in skill, ( - "connect/SKILL.md must still route ServiceNow" - ) - - runner = ( - _SOLUTION - / "src" - / "skills" - / "connect" - / "shared" - / "lifecycle-runner.md" - ).read_text(encoding="utf-8") - assert "status **outside** that phase's" in runner - assert "set it to `in-progress`" in runner - assert "checkpointAcknowledgements" in runner - assert "persisted `checkpointAcknowledgements`" in runner - assert "allowed `Manual`/`Warning` result lacks" in runner - - lifecycle_schema = ( - _SOLUTION - / "src" - / "skills" - / "connect" - / "shared" - / "lifecycle-contract-schema.md" - ).read_text(encoding="utf-8") - assert "checkpointAcknowledgements" in lifecycle_schema - assert "live status still matches" in lifecycle_schema - - updater = ( - _SOLUTION - / "src" - / "skills" - / "setup" - / "workday-da" - / "shared" - / "checklist-updater.md" - ).read_text(encoding="utf-8") - assert 'RESULT_SOURCE` — `"flightcheck"`' in updater - assert 'RESULT_SOURCE="external"' in updater - assert "calling playbook has\n already shown `EXTERNAL_EVIDENCE`" in updater - - entra = ( - _SOLUTION - / "src" - / "skills" - / "setup" - / "workday-da" - / "provision-entra-app.md" - ).read_text(encoding="utf-8") - assert "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" in entra - assert "label-to-app mapping" in entra - - power_platform = ( - _SOLUTION - / "src" - / "skills" - / "setup" - / "workday-da" - / "configure-power-platform.md" - ).read_text(encoding="utf-8") - assert "exactly one MCSBot delegated authorization" in power_platform - assert "contains no `[FAIL]` line" in power_platform - assert "`would create` authorization/team operation" in power_platform - - authorization_readme = ( - _SOLUTION / "scripts" / "alm" / "README.md" - ).read_text(encoding="utf-8") - assert "Current source-version limitation" in authorization_readme - assert "exactly one MCSBot delegated authorization" in authorization_readme - assert "more than one team is returned" in authorization_readme - - def test_router_dispatches_to_skill1_playbook(self): - text = _ROUTER.read_text(encoding="utf-8") - assert ( - "src/skills/setup/workday/provision-power-platform-environment.md" in text - ), "router must dispatch S1.1/S1.2 to the skill-1 playbook" - assert _SKILL1.is_file(), f"missing skill-1 playbook: {_SKILL1}" - - def test_router_dispatches_to_skill2_playbook(self): - text = _ROUTER.read_text(encoding="utf-8") - assert ( - "src/skills/setup/workday/install-ess.md" in text - ), "router must dispatch S2.1 to the skill-2 install-ess playbook" - assert _SKILL2.is_file(), f"missing skill-2 playbook: {_SKILL2}" - # Every skill (S1-S6) is now wired: no not-available-yet stub remains. - assert "not available yet" not in text, ( - "router must not carry a not-available-yet stub once skill-6 is wired" - ) - - def test_router_dispatches_to_skill3_playbook(self): - text = _ROUTER.read_text(encoding="utf-8") - assert ( - "src/skills/setup/workday/provision-workday-entra-app.md" in text - ), "router must dispatch S3.1-S3.7 to the skill-3 Entra-app playbook" - assert _SKILL3.is_file(), f"missing skill-3 playbook: {_SKILL3}" - # S3.x must no longer be in the not-available-yet stub range. - assert "S3.1 through S6.2 — not available yet" not in text, ( - "router stub range must start at S5.1 once skill-4 is wired" - ) - - def test_router_dispatches_to_skill4_playbook(self): - text = _ROUTER.read_text(encoding="utf-8") - assert ( - "src/skills/setup/workday/configure-workday-tenant.md" in text - ), "router must dispatch S4.1-S4.4 to the skill-4 tenant playbook" - assert _SKILL4.is_file(), f"missing skill-4 playbook: {_SKILL4}" - # S4.x must no longer be in the not-available-yet stub range. - assert "S4.1 through S6.2 — not available yet" not in text, ( - "router stub range must start at S5.1 once skill-4 is wired" - ) - - def test_router_dispatches_to_skill5_playbook(self): - text = _ROUTER.read_text(encoding="utf-8") - assert ( - "src/skills/setup/workday/install-workday-extension-pack.md" in text - ), "router must dispatch S5.1-S5.8 to the skill-5 extension-pack playbook" - assert _SKILL5.is_file(), f"missing skill-5 playbook: {_SKILL5}" - # S5.x must no longer be in the not-available-yet stub range. - assert "S5.1 through S6.2 — not available yet" not in text, ( - "router stub range must start at S6.1 once skill-5 is wired" - ) - - def test_router_dispatches_to_skill6_playbook(self): - text = _ROUTER.read_text(encoding="utf-8") - assert ( - "src/skills/setup/workday/create-new-topic.md" in text - ), "router must dispatch S6.1-S6.3 to the skill-6 create-new-topic playbook" - assert _SKILL6.is_file(), f"missing skill-6 playbook: {_SKILL6}" - # skill-6 is the last skill: the not-available-yet stub must be gone. - assert "not available yet" not in text, ( - "router must not carry a not-available-yet stub once skill-6 is wired" - ) - - def test_router_offers_optional_ootb_topics_between_skill5_and_skill6(self): - # The optional ready-made-topics installer is offered after skill-5 and - # before skill-6. It is an opt-in interstitial (not a tracked S-row), - # gated by ootbTopics.state so a resumed setup never re-prompts. - text = _ROUTER.read_text(encoding="utf-8") - assert ( - "src/skills/setup/workday/install-workday-ootb-topics.md" in text - ), "router must offer the optional OOTB-topics installer playbook" - assert _OOTB.is_file(), f"missing OOTB installer playbook: {_OOTB}" - assert "ootbTopics" in text, ( - "router must gate the optional offer on ootbTopics.state so it is " - "not re-prompted after install/decline" - ) - # The offer must sit between the skill-5 and skill-6 dispatch blocks. - pack_idx = text.index("install-workday-extension-pack.md") - ootb_idx = text.index("install-workday-ootb-topics.md") - create_idx = text.index("create-new-topic.md") - assert pack_idx < ootb_idx < create_idx, ( - "the OOTB-topics offer must appear after the skill-5 dispatch and " - "before the skill-6 dispatch" - ) - - def test_ootb_offer_gated_on_persistent_state_not_transient_resume(self): - # Regression: the offer used to be gated on the transient "first - # non-done row is S6.1" resume condition, so once S6 completed the - # offer was never shown again and was silently skipped in the S5->S6 - # handoff. It must instead be anchored to the persistent - # ootbTopics.state flag in two places: (1) a gate at the S6 dispatch - # entry (runs before skill-6), and (2) an All-done safety net for a - # setup that reached S6 before the offer was ever shown. - text = _ROUTER.read_text(encoding="utf-8") - - # (1) The S6 dispatch block gates on ootbTopics.state BEFORE it reads - # the skill-6 playbook. - s6_idx = text.index("### S6.1 through S6.3") - create_idx = text.index("create-new-topic.md") - s6_gate = text[s6_idx:create_idx] - assert "ootbTopics.state" in s6_gate, ( - "the S6 dispatch block must run the OOTB offer gated on " - "ootbTopics.state BEFORE reading create-new-topic.md, so the offer " - "cannot be skipped in the S5->S6 handoff" - ) - - # (2) The All-done path rescues a setup that never saw the offer. - done_idx = text.index("If **every** item is `done`") - assert "ootbTopics" in text[done_idx:done_idx + 600], ( - "the All-done path must run the OOTB offer as a safety net when " - "ootbTopics.state is unset, so a setup that reached S6 before the " - "offer was shown still gets it" - ) - - def test_router_renders_from_template(self): - text = _ROUTER.read_text(encoding="utf-8") - assert "src/skills/setup/workday/tasks.md" in text, ( - "router must render the working copy from the checklist template" - ) - assert _TEMPLATE.is_file(), f"missing checklist template: {_TEMPLATE}" - - def test_router_resumes_from_setupstatus(self): - text = _ROUTER.read_text(encoding="utf-8") - assert "setupStatus" in text, ( - "router must resume from setupStatus (durable source of truth)" - ) - assert ".local/connect/workday/config.json" in text, ( - "router must read setupStatus from .local/connect/workday/config.json" - ) - - def test_router_uses_local_state_paths(self): - # DD-CW5: state lives under .local/, never the plan's stale my/ prefix. - text = _ROUTER.read_text(encoding="utf-8") - assert ".local/setup/workday/tasks.md" in text - assert "my/setup/workday" not in text - - def test_all_referenced_paths_resolve(self): - text = _ROUTER.read_text(encoding="utf-8") - referenced = set(_PATH_RE.findall(text)) - assert referenced, "router references no src/skills paths — wiring changed?" - missing = [p for p in sorted(referenced) if not (_SOLUTION / p).is_file()] - assert not missing, f"router references missing files: {missing}" - - def test_router_checklist_mirrors_template(self): - # The router renders the resume checklist grouped exactly like the - # template. Its Message block duplicates the template's group headings and - # item titles, so pin parity: every heading/title in the template must - # appear in the router, or the displayed checklist silently drifts. - router = _ROUTER.read_text(encoding="utf-8") - template = _TEMPLATE.read_text(encoding="utf-8") - titles = re.findall( - r"^- \[[ xX]\]\s+\*\*(.+?)\*\*\s+\u2014", template, re.MULTILINE - ) - assert len(titles) == 25, ( - f"expected 25 item titles in template, parsed {len(titles)}" - ) - missing_titles = [t for t in titles if t not in router] - assert not missing_titles, ( - f"router checklist is missing item titles from the template: {missing_titles}" - ) - headings = re.findall(r"^###\s+\d+\.\s+(.+?)\s*$", template, re.MULTILINE) - assert headings, "no group headings parsed from the template" - missing_headings = [h for h in headings if h not in router] - assert not missing_headings, ( - f"router checklist is missing group headings from the template: {missing_headings}" - ) +def test_connect_workday_routes_by_architecture_and_install_state() -> None: + text = _CONNECT_STEP1.read_text(encoding="utf-8") + + assert "src/skills/setup/workday-da/SKILL.md" in text + assert "src/skills/connect/workday/SKILL.md" in text + assert "src/skills/setup/SKILL.md" in text + assert "WD-DA-PKG-001" in text + assert "WD-PKG-001" in text + assert "ESS DA Hub is not supported" in text + assert "Passed` + simplified-install result" in text + assert "Passed` + full / legacy result" in text + assert "do not treat it as a fresh environment" in text + assert "connect/workday/step" not in text + + +def test_connect_workday_provider_contract_and_review_guards() -> None: + contract_path = _WORKDAY_PROVIDER_DIR / "contract.json" + contract = json.loads(contract_path.read_text(encoding="utf-8")) + assert contract["detect"]["meansInstalled"] == "Passed" + assert contract["detect"]["meansNotInstalled"] == "NotConfigured" + assert (_WORKDAY_PROVIDER_DIR / "SKILL.md").is_file() + assert not list(_WORKDAY_PROVIDER_DIR.glob("step*.md")) + + runner = ( + _SOLUTION + / "src" + / "skills" + / "connect" + / "shared" + / "lifecycle-runner.md" + ).read_text(encoding="utf-8") + assert "checkpointAcknowledgements" in runner + assert "allowed `Manual`/`Warning` result lacks" in runner + + da_skill = ( + _SOLUTION / "src" / "skills" / "setup" / "workday-da" / "SKILL.md" + ).read_text(encoding="utf-8") + assert "installed-agent inventory resolves exactly one" in da_skill + assert "stable agent slug and `botId`" in da_skill + assert 'provider `status` to be `"ready"`' in da_skill + + updater = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "shared" + / "checklist-updater.md" + ).read_text(encoding="utf-8") + assert 'RESULT_SOURCE="external"' in updater + assert "already shown `EXTERNAL_EVIDENCE`" in updater + + entra = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "provision-entra-app.md" + ).read_text(encoding="utf-8") + assert "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" in entra + assert "label-to-app mapping" in entra + + power_platform = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "configure-power-platform.md" + ).read_text(encoding="utf-8") + assert "exactly one MCSBot delegated authorization" in power_platform + assert "contains no `[FAIL]` line" in power_platform + + +def test_hybrid_boundary_remains_non_mutating() -> None: + text = _BOUNDARY.read_text(encoding="utf-8") + + assert "Hybrid Workday extension setup is not available" in text + assert "Do not run the retained Workday setup playbooks" in text + assert "no Dataverse environment, solution, connection" in text + for playbook in _WORKDAY_PLAYBOOKS: + assert playbook not in text + + +def test_retained_legacy_playbooks_are_not_directly_routed() -> None: + playbook_dir = _SOLUTION / "src" / "skills" / "setup" / "workday" + boundary = _BOUNDARY.read_text(encoding="utf-8") + connect = _CONNECT_SKILL.read_text(encoding="utf-8") + step1 = _CONNECT_STEP1.read_text(encoding="utf-8") + + for playbook in _WORKDAY_PLAYBOOKS: + assert (playbook_dir / playbook).is_file(), playbook + assert playbook not in boundary + assert playbook not in connect + assert playbook not in step1 + + +def test_retired_workday_prompt_remains_absent() -> None: + assert not _OLD_PROMPT.exists() From 78e601fc4e7f95232ef03bae5cb12860356ebe3d Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 11:23:10 -0700 Subject: [PATCH 08/17] fix(connect): integrate Workday with MOS agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/flightcheck/checks/workday_da.py | 40 ++++++++++- .../scripts/flightcheck/cli.py | 6 +- .../src/skills/connect/step1.md | 71 +++++++++---------- .../src/skills/setup/workday-da/SKILL.md | 16 ++--- .../workday-da/configure-power-platform.md | 4 +- .../setup/workday-da/install-extension.md | 35 ++++++--- .../setup/workday-da/shared/config-schema.md | 14 ++-- .../setup/workday-da/verify-connection.md | 2 +- tests/flightcheck/checks/test_workday_da.py | 54 +++++++++++++- tests/flightcheck/test_cli.py | 4 +- .../flightcheck/test_cli_single_checkpoint.py | 44 ++++++++++++ tests/setup/test_da_setup_router.py | 3 +- tests/setup/test_setup_router.py | 29 +++++++- 13 files changed, 249 insertions(+), 73 deletions(-) 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 b33e2c684..2af4f01fb 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -100,8 +100,44 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: s.get("uniquename", "").casefold(): s for s in all_solutions } - hr_installed = _DA_HR_PARENT_SCHEMA in installed_names - it_installed = _DA_IT_PARENT_SCHEMA in installed_names + config = getattr(runner, "config", {}) or {} + agents = config.get("agents") if isinstance(config, dict) else [] + active_slug = config.get("activeAgent") if isinstance(config, dict) else None + active_agent = next( + ( + agent + for agent in agents or [] + if isinstance(agent, dict) and agent.get("slug") == active_slug + ), + None, + ) + if not isinstance(active_agent, dict): + candidate = config.get("agent") if isinstance(config, dict) else None + active_agent = candidate if isinstance(candidate, dict) else {} + active_schema = str( + active_agent.get("schemaName") + or active_agent.get("schema_name") + or "" + ).casefold() + + if active_schema == _DA_IT_PARENT_SCHEMA: + return [_result( + Status.FAILED.value, + "The active agent is the ESS DA IT agent.", + remediation=( + "Workday integration with the ESS IT Agent is not supported " + "in this release. Select the ESS HR Agent first." + ), + )] + + hr_installed = ( + active_schema == _DA_HR_PARENT_SCHEMA + or _DA_HR_PARENT_SCHEMA in installed_names + ) + it_installed = ( + active_schema == _DA_IT_PARENT_SCHEMA + or _DA_IT_PARENT_SCHEMA in installed_names + ) if not hr_installed and it_installed: return [_result( diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 53a108c3d..3f5a8da9d 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -103,7 +103,7 @@ ("Workday", run_workday_checks), ("Workday Extension", run_workday_extension_checks), ], - "workdayda": [("Solution", run_solution_checks), ("Workday DA", run_workday_da_checks)], + "workdayda": [("Workday DA", run_workday_da_checks)], "topics": [("Workday Topics", run_topic_checks)], "graphconnector": [ ("External Systems", run_external_systems_checks), @@ -740,6 +740,10 @@ def _merge_connect_config(config: dict, connect_config_path: str | None) -> dict for key, value in overlay.items(): if key not in foundation_keys: merged[key] = value + if not merged.get("dataverseEndpoint"): + sidecar_endpoint = overlay.get("sidecarDataverseEndpoint") + if isinstance(sidecar_endpoint, str) and sidecar_endpoint.strip(): + merged["dataverseEndpoint"] = sidecar_endpoint.strip() merged["_connectConfigPath"] = connect_config_path return merged diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index ddfb4ec62..50b40195e 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -234,48 +234,44 @@ Now read `src/skills/connect/servicenow/step1.md` and follow it. ### If the user chose Workday (2 or "workday") -First find out which kind of ESS agent is in this environment. Read -`.local/setup/config.json` and look at -`selected_products` (each entry is one of `da.esshr` / `da.essit` / `da.esshub` -/ `cea.esshr` / `cea.essit` / `cea.esshub`): +First find out which ESS agent is active. Read `.local/config.json`, resolve +`activeAgent` against `agents`, and fall back to the legacy `agent` object only +when needed. For a DA agent, also read the canonical +`.local/setup/config.json` `agents` record keyed by the active `botId` and +require `connect_ready: true`. -- **No `.local/setup/config.json`, or `selected_products` is empty** — no ESS - agent has been installed in this environment yet. +- **No concrete active agent, missing schema/architecture identity, or a DA + agent whose canonical setup is not connect-ready** — setup is incomplete. **Message:** - I don't see an Employee Self-Service agent installed in this environment yet. - Run `/setup` first, then come back and run `/connect workday` again. + I don't see a setup-complete Employee Self-Service agent selected in this + workspace yet. Run `/setup` or select the intended agent first, then come + back and run `/connect workday` again. **End message.** Stop here. -Resolve the active agent from `.local/config.json` (`activeAgent`, then the -matching entry in `agents`; fall back to `agent` only for the legacy -single-agent shape). Route by the resolved active agent's architecture before -consulting the installed-product inventory: +Route from the active agent's `releaseLine` and schema name +(`schemaName`/`schema_name`): - active `msdyn_copilotforemployeeselfservicedahr` or `msdyn_copilotforemployeeselfservicedait` → use the DA rules below; -- active CEA agent → skip to **CEA agent** below, even if DA products are also - installed. +- another agent with `releaseLine: "da"` → use the DA unsupported-target + rules below; +- active CEA agent → skip to **CEA agent** below. -If no active agent can be resolved, infer the architecture only when every -selected product belongs to the same architecture. If DA and CEA products are -both installed, stop and ask the maker to select the intended agent before -running `/connect workday` again. Never choose an architecture from whichever -product appears first. +Never infer the target from a retired `selected_products` field or from the +first installed agent in a multi-agent workspace. ### DA HR agent -Enter this branch only when the active agent is DA or the unresolved inventory -contains only `da.*` products. Use the active agent's `schemaName` and the -selected product inventory to determine the DA vertical. +Enter this branch only when the active agent is DA. Use its schema name to +determine the vertical. **ESS DA IT is not supported in this release.** If the active agent is -`msdyn_copilotforemployeeselfservicedait`, or the only selected DA vertical is -`da.essit`, show: +`msdyn_copilotforemployeeselfservicedait`, show: **Message:** @@ -288,7 +284,7 @@ Stop immediately. Do not create DA Workday state, run a Workday package checkpoint, install a package, or enter any DA Workday lifecycle step. **ESS DA Hub is not supported in this release.** If the active agent is the -ESS DA Hub, or the only selected DA product is `da.esshub`, show: +ESS DA Hub, show: **Message:** @@ -300,14 +296,9 @@ Please select the ESS HR Agent or contact your administrator. Stop immediately without creating or updating Workday state. **ESS DA HR is supported.** Continue only when the active agent is -`msdyn_copilotforemployeeselfservicedahr`, or the selected DA inventory -unambiguously contains only `da.esshr`. Do not run `WD-PKG-001` or the CEA -lifecycle: DA packages share some Workday connection-reference names with -CEA, so that checkpoint is not an architecture discriminator. - -If both DA HR and DA IT are installed but the active agent cannot be resolved, -stop and ask the maker to select the ESS HR Agent before running -`/connect workday` again. Never guess the vertical. +`msdyn_copilotforemployeeselfservicedahr`. Do not run `WD-PKG-001` or the CEA +lifecycle: DA packages share some Workday connection-reference names with CEA, +so that checkpoint is not an architecture discriminator. Read `src/skills/setup/workday-da/SKILL.md` and follow it. That skill runs `WD-DA-PKG-001`, installs or verifies the DA HR Workday child package, and @@ -316,8 +307,7 @@ bot-to-flow authorization, topic selection, and signed-in runtime validation. ### CEA agent -Enter this branch when the resolved active agent is CEA, or when no active -agent is resolvable and every selected product is `cea.*`. +Enter this branch when the resolved active agent is CEA. Check the current CEA Workday extension before honoring any existing lifecycle state: @@ -335,10 +325,13 @@ by both its status and detected flavor: exists, read `src/skills/connect/workday/SKILL.md` and follow it. - **`Passed` + full / legacy result** — do not run the simplified V2 wiring - lifecycle. Read `src/skills/setup/SKILL.md` and resume the full/legacy - Workday setup path, which handles legacy user context explicitly. -- **`NotConfigured`** — no Workday package is installed. Read - `src/skills/setup/SKILL.md` and start the resume-aware setup path. + lifecycle. Full/legacy CEA Workday setup is not available from the current + hybrid setup boundary; explain that this existing installation needs the + legacy CEA setup experience and stop without changing state. +- **`NotConfigured`** — no Workday package is installed. Fresh CEA Workday + installation is not available from the current hybrid setup boundary; + explain that this release supports the DA HR Workday path and stop without + changing state. - **`Failed`** — a partial or broken install was detected. Show the checkpoint remediation and stop; do not treat it as a fresh environment. - **`Warning` / `Skipped` / `Error`**, or a `Passed` result whose flavor cannot 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 5a9fdf1b2..2ccb7fe22 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 @@ -18,15 +18,13 @@ installed — that's owned by `/setup`, not by this skill. DA-1 checks for it an sends you to `/setup` first if it isn't there yet. This release does not support Workday for the ESS DA IT Agent. Before creating -the working checklist or reading provider state, resolve the active agent from -`.local/config.json` and read `.local/setup/config.json` `selected_products`. -Continue when the active agent resolves to an ESS DA HR agent instance with a -stable agent slug and `botId`. If no active agent is set, continue only when the -installed-agent inventory resolves exactly one ESS DA HR agent instance with -those fields; select that instance for this run before creating state. -`selected_products = ["da.esshr"]` alone is not enough because it identifies a -product, not a deployed agent. If the target is IT, Hub, CEA, mixed, ambiguous, -incomplete, or unresolved, show: +the working checklist or reading provider state, resolve `activeAgent` from +`.local/config.json` and require an ESS DA HR agent entry with a stable slug, +`botId`, and HR schema name. Then read the canonical +`.local/setup/config.json` `agents` record keyed by that `botId` and require +`connect_ready: true`. Do not use the retired `selected_products` field or +choose the first agent in a multi-agent workspace. If the target is IT, Hub, +CEA, ambiguous, incomplete, or unresolved, show: **Message:** 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 312eb13e4..9d383322c 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 @@ -147,7 +147,9 @@ supported by the script syntax. Resolve parameters instead of asking the maker to paste GUIDs: -- `OrgUrl`: `.local/config.json` → `dataverseEndpoint`. +- `OrgUrl`: `.local/config.json` `dataverseEndpoint` when present; otherwise + `.local/connect/workday-da/config.json` `sidecarDataverseEndpoint`. This must + be the same effective Dataverse environment used by DA-1. - `BotId`: active ESS DA HR agent → `agent.botId`. - `WorkflowId[]`: the target Workday workflow IDs referenced by the active agent's Workday topics. Resolve topic `flowId` values to Dataverse 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 7e6e95197..678a8a18e 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 @@ -15,11 +15,28 @@ doesn't support it. The router must stop DA IT agents before this file is read. ## P1.0 — Check for the DA base agent, and install the extension if it's missing +Resolve the Dataverse environment that hosts the Workday extension: + +1. Use `.local/config.json` `dataverseEndpoint` when present (legacy + Dataverse-backed workspace). +2. Otherwise read `.local/connect/workday-da/config.json` + `sidecarDataverseEndpoint`. +3. If neither exists, ask the maker for the target Power Platform environment + URL shown in the Power Platform admin center. Explain that the MOS/native + agent remains in AgentBuilder while the Workday solution, connections, and + cloud flows require this Dataverse sidecar environment. Require an HTTPS + `*.crm*.dynamics.com` organization URL, show it back for confirmation, then + persist it as `sidecarDataverseEndpoint`. + +Call the resolved value `WORKDAY_DATAVERSE_URL`. Never copy it into +`.local/config.json`; that file's native `powerPlatformApiEndpoint` remains the +agent identity boundary. + Run the checkpoint that reports both facts at once — whether a DA base agent exists, and whether Workday is already installed against it: ``` -python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 +python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 --connect-config ".local/connect/workday-da/config.json" ``` Read the checkpoint result from `workspace/flightcheck/results.json`. The only @@ -58,17 +75,17 @@ environment is outside this lifecycle and must not affect DA1.1. ## P1.1 — Attempt an automated install ``` -python scripts/install_workday_da_extension.py --url "{ENVIRONMENT_URL}" --vertical "hr" +python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" ``` -Run the command once. `ENVIRONMENT_URL` is the Dataverse endpoint from -`.local/config.json`. +Run the command once. Parse the script's JSON marker line: - **`INSTALLED_WORKDAY_DA_EXTENSION_JSON:`** → the HR package installed (or was already installed and the script confirmed it). Re-run - `--checkpoint WD-DA-PKG-001`; proceed only when it reports `PASSED`. +`--checkpoint WD-DA-PKG-001` with the same `--connect-config`; proceed only +when it reports `PASSED`. - **`WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:`** → this tenant's Marketplace catalog doesn't list the Workday extension package for the DA agent, so it can't be installed automatically here. This is expected in some tenants — @@ -84,7 +101,8 @@ Parse the script's JSON marker line: **End message.** - Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001`. + Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001` with + the same `--connect-config`. Loop until it passes, or fall back to **P1.2** if the user reports the install failed in the portal. - Any other exit / error → show the error verbatim and continue to **P1.2** @@ -109,8 +127,9 @@ installed the base Employee Self-Service agent: **End message.** -Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001`. Loop -until it passes. While it is not yet passing, keep DA1.1 `in-progress`. +Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001` with the +same `--connect-config`. Loop until it passes. While it is not yet passing, +keep DA1.1 `in-progress`. --- 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 8d37e4a81..8d22b60ae 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 @@ -20,14 +20,14 @@ There are **two distinct** files. Keep them separate. | File | Owner | Purpose | |------|-------|---------| -| `.local/connect/workday-da/config.json` | the `connect/workday-da` skill | Workday connection state — URLs, tenant, Entra app, OAuth client, per-step status. **This schema.** | -| `.local/config.json` | foundation-setup + flightcheck | Agent identity, `dataverseEndpoint`, and the installed-agent inventory (`agent`/`agents`). `scripts/flightcheck/cli.py` reads this for the Dataverse endpoint. **Not this schema.** | +| `.local/connect/workday-da/config.json` | the `connect/workday-da` skill | Workday connection state — sidecar Dataverse URL, Workday URLs, tenant, Entra app, OAuth client, per-step status. **This schema.** | +| `.local/config.json` | foundation setup + FlightCheck | AgentBuilder-native identity (`powerPlatformApiEndpoint`, `activeAgent`, `agent`/`agents`) and, for legacy workspaces only, a foundation `dataverseEndpoint`. **Not this schema.** | Never write Workday connection fields into `.local/config.json`, and never -write agent identity / `dataverseEndpoint` into -`.local/connect/workday-da/config.json`. The only crossover is read-only: a -step may *read* `.local/config.json` for `dataverseEndpoint` / `agent.botId` -when it needs them. +write agent identity into `.local/connect/workday-da/config.json`. A native MOS +agent may use `sidecarDataverseEndpoint` in this schema for the Dataverse +environment hosting the Workday solution and flows; FlightCheck consumes it +only when foundation config has no `dataverseEndpoint`. --- @@ -41,6 +41,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. | | `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. | @@ -110,6 +111,7 @@ authorization script. ```json { + "sidecarDataverseEndpoint": "https://contoso.crm.dynamics.com", "baseUrl": "https://wd2-impl.workday.com", "tenant": "acme_dpt1", "tokenHost": "wd2-impl-services1.workday.com", 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 6bce2e8d8..869a39d38 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 @@ -17,7 +17,7 @@ files you are reading. **Re-confirm the extension package.** ``` -python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 +python scripts/flightcheck/cli.py --checkpoint WD-DA-PKG-001 --connect-config ".local/connect/workday-da/config.json" ``` Show the result per [`shared/checklist-updater.md`](shared/checklist-updater.md) diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py index 9543e3789..ca6b45ed9 100644 --- a/tests/flightcheck/checks/test_workday_da.py +++ b/tests/flightcheck/checks/test_workday_da.py @@ -14,7 +14,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any import pytest @@ -56,6 +56,7 @@ class _MinimalRunner: env_url: str | None dv_token: str | None + config: dict[str, Any] = field(default_factory=dict) @pytest.fixture @@ -145,6 +146,57 @@ def test_failed_when_hr_base_agent_present_but_workday_child_missing( assert "AppSource" in r.remediation +@responses.activate +def test_native_hr_agent_only_requires_child_in_sidecar( + runner: _MinimalRunner, +) -> None: + runner.config = { + "releaseLine": "da", + "activeAgent": "ess-hr", + "agents": [ + { + "slug": "ess-hr", + "schemaName": "msdyn_copilotforemployeeselfservicedahr", + } + ], + } + _register_solutions( + solutions=[ + _solution_record("msdyn_essdahrworkday", version="2.1.0.0"), + ] + ) + + result = _check_workday_da_package_installed(runner)[0] + assert result.status == "Passed" + assert "msdyn_essdahrworkday" in result.result + + +@responses.activate +def test_native_it_active_agent_is_rejected( + runner: _MinimalRunner, +) -> None: + runner.config = { + "releaseLine": "da", + "activeAgent": "ess-it", + "agents": [ + { + "slug": "ess-it", + "schemaName": "msdyn_copilotforemployeeselfservicedait", + } + ], + } + _register_solutions( + solutions=[ + _solution_record("msdyn_copilotforemployeeselfservicedahr"), + _solution_record("msdyn_essdahrworkday"), + ] + ) + + result = _check_workday_da_package_installed(runner)[0] + assert result.status == "Failed" + assert "active agent is the ESS DA IT agent" in result.result + + @responses.activate def test_failed_when_only_it_base_agent_is_present( runner: _MinimalRunner, diff --git a/tests/flightcheck/test_cli.py b/tests/flightcheck/test_cli.py index 3263c1687..1d400fdef 100644 --- a/tests/flightcheck/test_cli.py +++ b/tests/flightcheck/test_cli.py @@ -30,7 +30,9 @@ def test_workday_da_check_is_explicit_scope_only() -> None: """An optional DA HR package must not fail unrelated full runs.""" - assert ("Workday DA", cli.run_workday_da_checks) in cli.SCOPE_MAP["workdayda"] + assert cli.SCOPE_MAP["workdayda"] == [ + ("Workday DA", cli.run_workday_da_checks) + ] assert ("Workday DA", cli.run_workday_da_checks) not in cli.FULL_SCOPE diff --git a/tests/flightcheck/test_cli_single_checkpoint.py b/tests/flightcheck/test_cli_single_checkpoint.py index 8f5741624..bd28f1857 100644 --- a/tests/flightcheck/test_cli_single_checkpoint.py +++ b/tests/flightcheck/test_cli_single_checkpoint.py @@ -120,6 +120,50 @@ def test_connect_config_overlay_preserves_foundation_connections( assert merged["connections"]["Workday"]["tenant"] == "foundation" + def test_connect_config_supplies_native_sidecar_dataverse( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"sidecarDataverseEndpoint":' + '"https://sidecar.crm.dynamics.com"}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + {"powerPlatformApiEndpoint": "https://api.powerplatform.com"}, + str(overlay), + ) + + assert ( + merged["dataverseEndpoint"] + == "https://sidecar.crm.dynamics.com" + ) + assert ( + merged["powerPlatformApiEndpoint"] + == "https://api.powerplatform.com" + ) + + def test_foundation_dataverse_wins_over_sidecar( + self, tmp_path: Path + ) -> None: + overlay = tmp_path / "workday-da.json" + overlay.write_text( + '{"sidecarDataverseEndpoint":' + '"https://sidecar.crm.dynamics.com"}', + encoding="utf-8", + ) + + merged = cli._merge_connect_config( + {"dataverseEndpoint": "https://foundation.crm.dynamics.com"}, + str(overlay), + ) + + assert ( + merged["dataverseEndpoint"] + == "https://foundation.crm.dynamics.com" + ) + def test_connect_config_overlay_rejects_non_object( self, tmp_path: Path ) -> None: diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 679301c90..540cd08ad 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -233,7 +233,8 @@ def test_workday_routing_remains_separate() -> None: step1 = _CONNECT_STEP1.read_text(encoding="utf-8") workday = _WORKDAY.read_text(encoding="utf-8") - assert "src/skills/setup/SKILL.md" in step1 + assert "src/skills/setup/workday-da/SKILL.md" in step1 + assert "Fresh CEA Workday" in step1 assert "src/skills/foundation-setup/SKILL.md" not in step1 assert _WORKDAY.is_file() assert "Hybrid Workday extension setup is not available" in workday diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 6fc2eab5b..1b67c88b7 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -32,13 +32,14 @@ def test_connect_workday_routes_by_architecture_and_install_state() -> None: assert "src/skills/setup/workday-da/SKILL.md" in text assert "src/skills/connect/workday/SKILL.md" in text - assert "src/skills/setup/SKILL.md" in text assert "WD-DA-PKG-001" in text assert "WD-PKG-001" in text assert "ESS DA Hub is not supported" in text assert "Passed` + simplified-install result" in text assert "Passed` + full / legacy result" in text assert "do not treat it as a fresh environment" in text + assert "retired `selected_products` field" in text + assert "Fresh CEA Workday\n installation is not available" in text assert "connect/workday/step" not in text @@ -64,8 +65,8 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: da_skill = ( _SOLUTION / "src" / "skills" / "setup" / "workday-da" / "SKILL.md" ).read_text(encoding="utf-8") - assert "installed-agent inventory resolves exactly one" in da_skill - assert "stable agent slug and `botId`" in da_skill + assert "canonical\n`.local/setup/config.json` `agents` record" in da_skill + assert "stable slug,\n`botId`" in da_skill assert 'provider `status` to be `"ready"`' in da_skill updater = ( @@ -102,6 +103,28 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: assert "exactly one MCSBot delegated authorization" in power_platform assert "contains no `[FAIL]` line" in power_platform + install = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "install-extension.md" + ).read_text(encoding="utf-8") + assert "sidecarDataverseEndpoint" in install + assert '--connect-config ".local/connect/workday-da/config.json"' in install + + verify = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "verify-connection.md" + ).read_text(encoding="utf-8") + assert '--connect-config ".local/connect/workday-da/config.json"' in verify + assert "sidecarDataverseEndpoint" in power_platform + def test_hybrid_boundary_remains_non_mutating() -> None: text = _BOUNDARY.read_text(encoding="utf-8") From 484971b078a419eb9b026b12173e8ac686e70dbb Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 11:27:27 -0700 Subject: [PATCH 09/17] fix(connect): initialize Workday provider state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/skills/setup/workday-da/install-extension.md | 4 +++- tests/setup/test_setup_router.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) 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 678a8a18e..40548d776 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 @@ -30,7 +30,9 @@ Resolve the Dataverse environment that hosts the Workday extension: Call the resolved value `WORKDAY_DATAVERSE_URL`. Never copy it into `.local/config.json`; that file's native `powerPlatformApiEndpoint` remains the -agent identity boundary. +agent identity boundary. If `.local/connect/workday-da/config.json` does not +yet exist, create it as an empty JSON object before the first checkpoint; if it +exists, preserve all current fields. Run the checkpoint that reports both facts at once — whether a DA base agent exists, and whether Workday is already installed against it: diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 1b67c88b7..d8f92bea2 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -112,6 +112,7 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: / "install-extension.md" ).read_text(encoding="utf-8") assert "sidecarDataverseEndpoint" in install + assert "create it as an empty JSON object" in install assert '--connect-config ".local/connect/workday-da/config.json"' in install verify = ( From 8abd001c1de5cdcf1eb38537fc2745d7c397fff1 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 14:30:12 -0700 Subject: [PATCH 10/17] fix(connect): require canonical routing evidence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/skills/connect/step1.md | 23 ++++++++++++++----- .../setup/workday-da/install-extension.md | 8 ++++--- tests/setup/test_setup_router.py | 4 ++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index 50b40195e..144c70c62 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -67,9 +67,22 @@ Wait for the user to respond. ### If the user chose ServiceNow (1 or "servicenow") -Resolve `.local/setup/config.json` `selected_products` and the active agent -record from `.local/config.json`. If the active agent is a Declarative Agent, -or the installed inventory contains only `da.*` products, show: +Resolve `activeAgent` against `.local/config.json` `agents`, falling back to +the legacy `agent` object only when needed. Do not consult the retired +`selected_products` field. + +If no concrete active agent with architecture identity can be resolved, show: + +**Message:** + +Select the Employee Self-Service agent you want to connect, then run +`/connect servicenow` again. + +**End message.** + +Stop immediately without creating or updating ServiceNow state. + +If the active agent is a Declarative Agent, show: **Message:** @@ -79,9 +92,7 @@ release. Please contact your administrator. **End message.** Stop immediately. Do not create ServiceNow state or enter the ServiceNow -lifecycle. If both CEA and DA products exist and no active agent can be -resolved, ask the maker to select an agent and run `/connect servicenow` -again; never guess the architecture. +lifecycle. Continue below only for a concrete active CEA agent. Check if `.local/connect/servicenow/steps.md` exists. 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 40548d776..f78b83631 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 @@ -68,9 +68,11 @@ environment is outside this lifecycle and must not affect DA1.1. yet. 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`** (Dataverse call failed, e.g. permissions or a transient error) - → show the result verbatim and stop; ask the user to resolve the underlying - issue (commonly a missing Dataverse role) and re-run this step. +- **`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. --- diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index d8f92bea2..e6b56aded 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -40,6 +40,8 @@ def test_connect_workday_routes_by_architecture_and_install_state() -> None: assert "do not treat it as a fresh environment" in text assert "retired `selected_products` field" in text assert "Fresh CEA Workday\n installation is not available" in text + assert "Do not consult the retired\n`selected_products` field" in text + assert "Continue below only for a concrete active CEA agent" in text assert "connect/workday/step" not in text @@ -114,6 +116,8 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: assert "sidecarDataverseEndpoint" in install assert "create it as an empty JSON object" in install assert '--connect-config ".local/connect/workday-da/config.json"' in install + assert "**`WARNING` / `SKIPPED`**" in install + assert "Never attempt package\n installation from an inconclusive result" in install verify = ( _SOLUTION From 39a5599db6b736f16170605529758090efa7522a Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 16:40:48 -0700 Subject: [PATCH 11/17] fix(connect): allow setup-materialized agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.github/copilot-instructions.md | 13 +++++++++++-- .../.github/prompts/connect.prompt.md | 14 +++++++------- .../ess-maker-skills/src/skills/connect/step1.md | 8 +++++--- .../src/skills/setup/workday-da/SKILL.md | 13 ++++++++----- tests/setup/test_da_setup_router.py | 16 ++++++++++++++-- tests/setup/test_setup_router.py | 6 ++++++ 6 files changed, 51 insertions(+), 19 deletions(-) diff --git a/solutions/ess-maker-skills/.github/copilot-instructions.md b/solutions/ess-maker-skills/.github/copilot-instructions.md index ab1c43952..2ff9c57ca 100644 --- a/solutions/ess-maker-skills/.github/copilot-instructions.md +++ b/solutions/ess-maker-skills/.github/copilot-instructions.md @@ -50,6 +50,14 @@ Respond with ONLY this exact message and nothing else: `flightCheckOnly: true`, proceed with `src/skills/flightcheck/SKILL.md`. This exception applies only to `/flightcheck`; every other command remains gated. +- If the user typed `/connect`, allow the command after **local workspace + materialization**, even when runtime `connect_ready` is false. Require + `schema_version: 4`, resolve `.local/config.json` `activeAgent` to the + canonical agent whose `agent.workspace_slug` matches, and require canonical + workspace evidence plus `steps.SETUP-07.state: "done"`. Connector readiness + is intentionally not a prerequisite because `/connect` is the workflow that + resolves product-extension connection gaps. If materialization is incomplete, + show the setup message above and stop. **Except for the cases above, this gate applies to ALL user messages** — including "hello", "hi", "help", @@ -79,8 +87,9 @@ After canonical DA setup is complete: available; - never run Dataverse push, publish, deletion, or server-backed validation instructions; explain that DA-GA deployment is not yet available; -- `/connect` and integration troubleshooting require the corresponding DA-GA - product extension guidance, which is not yet available; +- `/connect workday` uses the checked-in ESS DA HR Workday extension guidance; + unsupported products or agent verticals must stop at their explicit routing + boundary; - `/backup-template-configs` and `/restore-template-configs` are no longer supported because they belonged to the retired Dataverse-based agent model; - `/flightcheck` may run only its local-files scope. diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index 0998b440d..5219ff1f6 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -5,18 +5,18 @@ description: "Check DA-GA product extension setup availability" # Connect -**Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. If canonical state does not have `schema_version: 4` and an `agents` entry matching the active workspace slug with `connect_ready: true`, show: +**Setup-state check.** Read `.local/setup/config.json` and `.local/config.json`. +Resolve `activeAgent` to the canonical agent whose `agent.workspace_slug` +matches. Continue when canonical state has `schema_version: 4`, complete +workspace evidence, and `steps.SETUP-07.state: "done"`. Do not require +`connect_ready: true`; this command configures the product-extension +connections that may currently block runtime readiness. If local workspace +materialization is incomplete, show: > Welcome to the ESS Maker Kit. Before running `/connect`, type `/setup` to set up your environment. and STOP. Otherwise proceed. -Show: - -> DA-GA connector setup requires the corresponding product extension. Extension setup is not yet available in this release. - -and STOP. - You are a script executor. Read `src/skills/connect/SKILL.md` (a short router file) and follow it. It will tell you which step file to read next. Each step file contains pre-written messages between **Message:** and diff --git a/solutions/ess-maker-skills/src/skills/connect/step1.md b/solutions/ess-maker-skills/src/skills/connect/step1.md index 144c70c62..71e0f5af7 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -248,11 +248,13 @@ Now read `src/skills/connect/servicenow/step1.md` and follow it. First find out which ESS agent is active. Read `.local/config.json`, resolve `activeAgent` against `agents`, and fall back to the legacy `agent` object only when needed. For a DA agent, also read the canonical -`.local/setup/config.json` `agents` record keyed by the active `botId` and -require `connect_ready: true`. +`.local/setup/config.json` `agents` record keyed by the active `botId`. Require +canonical workspace evidence and `steps.SETUP-07.state: "done"`; do not require +`connect_ready: true`, because Workday connection configuration may be the +remaining readiness blocker. - **No concrete active agent, missing schema/architecture identity, or a DA - agent whose canonical setup is not connect-ready** — setup is incomplete. + agent whose local workspace is not materialized** — setup is incomplete. **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 2ccb7fe22..c5f70a839 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 @@ -22,9 +22,11 @@ the working checklist or reading provider state, resolve `activeAgent` from `.local/config.json` and require an ESS DA HR agent entry with a stable slug, `botId`, and HR schema name. Then read the canonical `.local/setup/config.json` `agents` record keyed by that `botId` and require -`connect_ready: true`. Do not use the retired `selected_products` field or -choose the first agent in a multi-agent workspace. If the target is IT, Hub, -CEA, ambiguous, incomplete, or unresolved, show: +canonical workspace evidence plus `steps.SETUP-07.state: "done"`. Do not +require `connect_ready: true`; configuring Workday may resolve the remaining +runtime connection blocker. Do not use the retired `selected_products` field +or choose the first agent in a multi-agent workspace. If the target is IT, +Hub, CEA, ambiguous, incomplete, or unresolved, show: **Message:** @@ -245,7 +247,8 @@ When it returns, go back to **Start** — every row should now be `done`. **Message:** Your ESS DA HR Agent is connected to Workday and the signed-in employee path -has been validated in this environment. Type `/menu` to see what you can do -next. +has been validated in this environment. Run `/setup` once to refresh the +environment's runtime-readiness checks. This keeps the completed Workday +connection and shows any unrelated prerequisite that still needs attention. **End message.** diff --git a/tests/setup/test_da_setup_router.py b/tests/setup/test_da_setup_router.py index 540cd08ad..3767a31de 100644 --- a/tests/setup/test_da_setup_router.py +++ b/tests/setup/test_da_setup_router.py @@ -173,7 +173,6 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: gated_prompts = ( "backup-template-configs.prompt.md", - "connect.prompt.md", "create.prompt.md", "delete.prompt.md", "evaluate.prompt.md", @@ -203,6 +202,16 @@ def test_global_and_command_gates_require_canonical_da_completion() -> None: not in normalized ), path + connect_prompt = (_PROMPTS / "connect.prompt.md").read_text(encoding="utf-8") + assert "schema_version: 4" in connect_prompt + assert 'steps.SETUP-07.state: "done"' in connect_prompt + assert "Do not require\n`connect_ready: true`" in connect_prompt + assert "workspace evidence" in connect_prompt + + assert "If the user typed `/connect`" in instructions + assert 'steps.SETUP-07.state: "done"' in instructions + assert "even when runtime `connect_ready` is false" in instructions + assert "`.local/config.json`'s" in instructions @@ -904,7 +913,6 @@ def test_da_commands_degrade_by_operation() -> None: expected_text = { "push.prompt.md": "DA-GA agent is not yet available", "delete.prompt.md": "DA-GA agent is not yet available", - "connect.prompt.md": "requires the corresponding product extension", "troubleshoot.prompt.md": ( "requires the corresponding product extension guidance" ), @@ -915,6 +923,10 @@ def test_da_commands_degrade_by_operation() -> None: assert text in prompt, name assert "transport" not in prompt.casefold(), name + connect_prompt = (_PROMPTS / "connect.prompt.md").read_text(encoding="utf-8") + assert "src/skills/connect/SKILL.md" in connect_prompt + assert "Extension setup is not yet available" not in connect_prompt + def test_hybrid_workday_config_commands_remain_available() -> None: routes = { diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index e6b56aded..66131d8a1 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -40,6 +40,8 @@ def test_connect_workday_routes_by_architecture_and_install_state() -> None: assert "do not treat it as a fresh environment" in text assert "retired `selected_products` field" in text assert "Fresh CEA Workday\n installation is not available" in text + assert 'steps.SETUP-07.state: "done"' in text + assert "do not require\n`connect_ready: true`" in text assert "Do not consult the retired\n`selected_products` field" in text assert "Continue below only for a concrete active CEA agent" in text assert "connect/workday/step" not in text @@ -69,6 +71,10 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: ).read_text(encoding="utf-8") assert "canonical\n`.local/setup/config.json` `agents` record" in da_skill assert "stable slug,\n`botId`" in da_skill + assert 'steps.SETUP-07.state: "done"' in da_skill + assert "Do not\nrequire `connect_ready: true`" in da_skill + assert "Run `/setup` once to refresh the" in da_skill + assert "shows any unrelated prerequisite" in da_skill assert 'provider `status` to be `"ready"`' in da_skill updater = ( From 9f8df299e021dd60179576393eef88ef0e86ca8e Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 16:46:51 -0700 Subject: [PATCH 12/17] fix(connect): recognize MOS ESS agent schemas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/flightcheck/checks/workday_da.py | 14 +++++++++++--- .../ess-maker-skills/src/skills/connect/SKILL.md | 14 ++++++-------- .../ess-maker-skills/src/skills/connect/step1.md | 14 +++++++++----- tests/flightcheck/checks/test_workday_da.py | 4 ++-- tests/setup/test_setup_router.py | 6 ++++++ 5 files changed, 34 insertions(+), 18 deletions(-) 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 2af4f01fb..096eb50bf 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -23,6 +23,14 @@ _DA_HR_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedahr" _DA_IT_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedait" _DA_HR_WORKDAY_CHILD_SCHEMA = "msdyn_essdahrworkday" +_DA_HR_AGENT_SCHEMAS = { + _DA_HR_PARENT_SCHEMA, + "gptagent_copilotforemployeeselfservicehr", +} +_DA_IT_AGENT_SCHEMAS = { + _DA_IT_PARENT_SCHEMA, + "gptagent_copilotforemployeeselfserviceit", +} _SOLN_SELECT = "solutionid,uniquename,friendlyname,ismanaged,version" @@ -120,7 +128,7 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: or "" ).casefold() - if active_schema == _DA_IT_PARENT_SCHEMA: + if active_schema in _DA_IT_AGENT_SCHEMAS: return [_result( Status.FAILED.value, "The active agent is the ESS DA IT agent.", @@ -131,11 +139,11 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: )] hr_installed = ( - active_schema == _DA_HR_PARENT_SCHEMA + active_schema in _DA_HR_AGENT_SCHEMAS or _DA_HR_PARENT_SCHEMA in installed_names ) it_installed = ( - active_schema == _DA_IT_PARENT_SCHEMA + active_schema in _DA_IT_AGENT_SCHEMAS or _DA_IT_PARENT_SCHEMA in installed_names ) diff --git a/solutions/ess-maker-skills/src/skills/connect/SKILL.md b/solutions/ess-maker-skills/src/skills/connect/SKILL.md index d052da981..1381c48c2 100644 --- a/solutions/ess-maker-skills/src/skills/connect/SKILL.md +++ b/solutions/ess-maker-skills/src/skills/connect/SKILL.md @@ -54,12 +54,9 @@ Workday routes by architecture before package detection: `.local/connect/workday/agents/{agent-slug}/lifecycle.json`. This same runner + contract pattern is what a future integration reuses — a new ISV only needs its own contract file and action fragments, not a new runner. - - **CEA, nothing installed yet** — the full **setup orchestrator** - (`src/skills/setup/SKILL.md`). It sequences the six CEA Workday setup - skills (environment, ESS install, Entra app, tenant config, extension - pack, topic) using the master checklist as a resume-aware spine. State: - `.local/setup/workday/tasks.md` + `setupStatus` in - `.local/connect/workday/config.json`. + - **CEA full/legacy package or nothing installed yet** — unsupported from + the current hybrid boundary. The router explains the limitation and stops + without reading `src/skills/setup/SKILL.md` or changing state. - **DA HR agent** — the **DA Workday connect skill** (`src/skills/setup/workday-da/SKILL.md`). It sequences five steps (extension package, Entra app, tenant config, Power Platform/agent @@ -72,8 +69,9 @@ Workday routes by architecture before package detection: stops before creating state or running any Workday lifecycle step and directs the maker to contact their administrator. - `src/skills/connect/step1.md` reads `.local/setup/config.json`'s - `selected_products` to choose the correct installation path. + `src/skills/connect/step1.md` resolves the active agent from + `.local/config.json` and its canonical materialization record from + `.local/setup/config.json`; it never routes from retired product inventory. 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 71e0f5af7..d896504d5 100644 --- a/solutions/ess-maker-skills/src/skills/connect/step1.md +++ b/solutions/ess-maker-skills/src/skills/connect/step1.md @@ -269,8 +269,10 @@ remaining readiness blocker. Route from the active agent's `releaseLine` and schema name (`schemaName`/`schema_name`): -- active `msdyn_copilotforemployeeselfservicedahr` or - `msdyn_copilotforemployeeselfservicedait` → use the DA rules below; +- active `gptagent_copilotforemployeeselfservicehr` (current MOS HR), + `gptagent_copilotforemployeeselfserviceit` (current MOS IT), or the legacy + `msdyn_copilotforemployeeselfservicedahr` / + `msdyn_copilotforemployeeselfservicedait` aliases → use the DA rules below; - another agent with `releaseLine: "da"` → use the DA unsupported-target rules below; - active CEA agent → skip to **CEA agent** below. @@ -284,6 +286,7 @@ Enter this branch only when the active agent is DA. Use its schema name to determine the vertical. **ESS DA IT is not supported in this release.** If the active agent is +`gptagent_copilotforemployeeselfserviceit` or `msdyn_copilotforemployeeselfservicedait`, show: **Message:** @@ -309,9 +312,10 @@ Please select the ESS HR Agent or contact your administrator. Stop immediately without creating or updating Workday state. **ESS DA HR is supported.** Continue only when the active agent is -`msdyn_copilotforemployeeselfservicedahr`. Do not run `WD-PKG-001` or the CEA -lifecycle: DA packages share some Workday connection-reference names with CEA, -so that checkpoint is not an architecture discriminator. +`gptagent_copilotforemployeeselfservicehr` or the legacy +`msdyn_copilotforemployeeselfservicedahr` alias. Do not run `WD-PKG-001` or the +CEA lifecycle: DA packages share some Workday connection-reference names with +CEA, so that checkpoint is not an architecture discriminator. Read `src/skills/setup/workday-da/SKILL.md` and follow it. That skill runs `WD-DA-PKG-001`, installs or verifies the DA HR Workday child package, and diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py index ca6b45ed9..580aa411b 100644 --- a/tests/flightcheck/checks/test_workday_da.py +++ b/tests/flightcheck/checks/test_workday_da.py @@ -156,7 +156,7 @@ def test_native_hr_agent_only_requires_child_in_sidecar( "agents": [ { "slug": "ess-hr", - "schemaName": "msdyn_copilotforemployeeselfservicedahr", + "schemaName": "gptagent_copilotforemployeeselfservicehr", } ], } @@ -181,7 +181,7 @@ def test_native_it_active_agent_is_rejected( "agents": [ { "slug": "ess-it", - "schemaName": "msdyn_copilotforemployeeselfservicedait", + "schemaName": "gptagent_copilotforemployeeselfserviceit", } ], } diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index 66131d8a1..e4d949869 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -34,6 +34,8 @@ def test_connect_workday_routes_by_architecture_and_install_state() -> None: assert "src/skills/connect/workday/SKILL.md" in text assert "WD-DA-PKG-001" in text assert "WD-PKG-001" in text + assert "gptagent_copilotforemployeeselfservicehr" in text + assert "gptagent_copilotforemployeeselfserviceit" in text assert "ESS DA Hub is not supported" in text assert "Passed` + simplified-install result" in text assert "Passed` + full / legacy result" in text @@ -44,6 +46,10 @@ def test_connect_workday_routes_by_architecture_and_install_state() -> None: assert "do not require\n`connect_ready: true`" in text assert "Do not consult the retired\n`selected_products` field" in text assert "Continue below only for a concrete active CEA agent" in text + + connect_skill = _CONNECT_SKILL.read_text(encoding="utf-8") + assert "unsupported from\n the current hybrid boundary" in connect_skill + assert "never routes from retired product inventory" in connect_skill assert "connect/workday/step" not in text From 9dd8b995dc93185ae170c18142b840a96d2d8cbf Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 17:17:45 -0700 Subject: [PATCH 13/17] fix(connect): automate Workday package setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 1 + .../.github/prompts/connect-workday.prompt.md | 11 ++ .../.github/prompts/connect.prompt.md | 2 +- .../.github/prompts/menu.prompt.md | 3 +- .../scripts/install_workday_da_extension.py | 121 ++++++++++++++---- .../setup/workday-da/install-extension.md | 56 ++++---- .../test_install_workday_da_extension.py | 104 ++++++++++++--- 7 files changed, 228 insertions(+), 70 deletions(-) create mode 100644 solutions/ess-maker-skills/.github/prompts/connect-workday.prompt.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 38da62aef..e72667258 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,6 +21,7 @@ For everything that isn't an attempt to use the kit (general questions, code exp - `/landing-page` - `/create` - `/connect` + - `/connect-workday` - `/delete` - `/evaluate` - `/run` diff --git a/solutions/ess-maker-skills/.github/prompts/connect-workday.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect-workday.prompt.md new file mode 100644 index 000000000..6b7bf9638 --- /dev/null +++ b/solutions/ess-maker-skills/.github/prompts/connect-workday.prompt.md @@ -0,0 +1,11 @@ +--- +mode: agent +description: "Connect the active ESS HR agent to Workday" +--- + +# Connect Workday + +Follow the same setup-state check and execution rules as +`.github/prompts/connect.prompt.md`, with `workday` supplied as the selected +integration. Then read `src/skills/connect/SKILL.md` and continue without +asking which system to connect. diff --git a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md index 5219ff1f6..bffb68f3c 100644 --- a/solutions/ess-maker-skills/.github/prompts/connect.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/connect.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: "Check DA-GA product extension setup availability" +description: "Connect Workday or another supported integration" --- # Connect diff --git a/solutions/ess-maker-skills/.github/prompts/menu.prompt.md b/solutions/ess-maker-skills/.github/prompts/menu.prompt.md index 6fc197a6f..61f50ab91 100644 --- a/solutions/ess-maker-skills/.github/prompts/menu.prompt.md +++ b/solutions/ess-maker-skills/.github/prompts/menu.prompt.md @@ -14,7 +14,8 @@ Here's what I can help you with: | Command | What it does | |---------|-------------| | `/landing-page` | Configure the branding and content employees see when they open the ESS agent | -| `/connect` | Show the DA-GA product extension requirement | +| `/connect-workday` | Connect the active ESS HR agent to Workday | +| `/connect` | Choose an available integration | | `/create` | Create a topic, workflow, or evaluation test set locally | | `/update` | Update a topic, workflow, or evaluation test set locally | | `/delete` | Show DA-GA deletion availability | 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 632396871..a6b28969f 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -1,23 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Attempt an automated install of the DA Workday extension package. +"""Install the DA HR Workday AppSource application. Used by ``src/skills/setup/workday-da/install-extension.md`` (step DA1.1). -The DA Workday extension package has no entry in -``src/reference/ess-agent-installation/config.json`` — there is no confirmed -Marketplace catalog record for it. This script therefore does not assume the -package is installable through the Marketplace application API; it only -*tries* through -``PowerPlatformClient.list_environment_application_packages`` / -``install_application_package``), and reports a distinct, honest outcome — -``not-listed`` — when the tenant's Marketplace catalog does not return the -package at all. The calling skill step must treat ``not-listed`` as "hand off -to the manual AppSource install", never as a failure. - -This mirrors the principle already stated for the CEA extension pack install -(``src/skills/setup/workday/install-workday-extension-pack.md``): never infer -success from an unsupported or incomplete API response. +The installer uses the same AppSource application name as the bootstrap skill +and reports a distinct ``not-listed`` outcome when the signed-in account cannot +see the application in the selected environment. """ from __future__ import annotations @@ -34,7 +23,16 @@ from flightcheck.powerplatform_client import PowerPlatformClient from flightcheck.pp_admin_client import PPAdminClient -WORKDAY_DA_CHILD_SCHEMAS = {"hr": _DA_HR_WORKDAY_CHILD_SCHEMA} +WORKDAY_DA_PACKAGES = { + "hr": { + "applicationName": "msdyn_EssDAHRWorkdayHCM", + "schemaName": _DA_HR_WORKDAY_CHILD_SCHEMA, + }, +} +WORKDAY_DA_CHILD_SCHEMAS = { + vertical: package["schemaName"] + for vertical, package in WORKDAY_DA_PACKAGES.items() +} INSTALLED_STATES = {"Installed", "TemplateInstalled"} IN_PROGRESS_STATES = { "InstallRequested", @@ -58,9 +56,9 @@ def __init__(self, unique_name: str, timeout_seconds: int): ) -def _find_package(packages: list[dict], schema_name: str) -> dict | None: - """Find the entitled application whose unique name matches the schema.""" - target = schema_name.casefold() +def _find_package(packages: list[dict], package_name: str) -> dict | None: + """Find an entitled application by unique or application name.""" + target = package_name.casefold() for package in packages: names = (package.get("uniqueName"), package.get("applicationName")) if any( @@ -164,6 +162,32 @@ def __init__(self, unique_name: str): ) +def resolve_dataverse_url( + environment_id: str, + *, + pp_admin_client_factory=PPAdminClient, +) -> str: + """Resolve the Dataverse URL for a setup-captured environment ID.""" + client = pp_admin_client_factory("organizations") + client.authenticate(include_flow=False) + environment = client.get_environment(environment_id) + if not isinstance(environment, dict) or environment.get("_error"): + raise RuntimeError( + "Could not read the selected Power Platform environment." + ) + linked = ( + environment.get("properties", {}) + .get("linkedEnvironmentMetadata", {}) + ) + for key in ("instanceUrl", "instanceApiUrl"): + value = linked.get(key) + if isinstance(value, str) and value.startswith("https://"): + return value.rstrip("/") + raise RuntimeError( + "The selected Power Platform environment does not have Dataverse." + ) + + def install_workday_da_extension( env_url: str, vertical: str, @@ -191,7 +215,9 @@ def install_workday_da_extension( "Workday integration with the ESS DA IT Agent is not supported " "in this release." ) - schema_name = WORKDAY_DA_CHILD_SCHEMAS[vertical] + package_config = WORKDAY_DA_PACKAGES[vertical] + schema_name = package_config["schemaName"] + application_name = package_config["applicationName"] tenant_id = discover_tenant(env_url) pp_admin = pp_admin_client_factory(tenant_id) @@ -204,11 +230,14 @@ def install_workday_da_extension( client = powerplatform_client_factory(tenant_id) client.authenticate() - package = _find_package(_list_packages(client, environment_id), schema_name) + package = _find_package( + _list_packages(client, environment_id), + application_name, + ) if not package: - raise ExtensionNotListedError(schema_name) + raise ExtensionNotListedError(application_name) - unique_name = package.get("uniqueName") or schema_name + unique_name = package.get("uniqueName") or application_name state = package.get("state") if state in INSTALLED_STATES: installation_state_callback("automatic-complete") @@ -255,23 +284,61 @@ def main() -> None: "package for one ESS vertical." ) ) - parser.add_argument("--url", required=True, help="Dataverse environment URL") + target = parser.add_mutually_exclusive_group(required=True) + target.add_argument("--url", help="Dataverse environment URL") + target.add_argument( + "--environment-id", + help="Power Platform environment ID captured during setup", + ) parser.add_argument( "--vertical", required=True, - choices=sorted(WORKDAY_DA_CHILD_SCHEMAS), + choices=sorted(WORKDAY_DA_PACKAGES), help="DA vertical (this release supports hr only)", ) + parser.add_argument( + "--resolve-only", + action="store_true", + help="Resolve and print the Dataverse URL without installing.", + ) args = parser.parse_args() + try: + environment_url = ( + args.url.rstrip("/") + if args.url + else resolve_dataverse_url(args.environment_id) + ) + except (OSError, RuntimeError, ValueError) as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) + + if args.resolve_only: + print( + "WORKDAY_DA_ENVIRONMENT_JSON:" + + json.dumps( + { + "environmentId": args.environment_id, + "environmentUrl": environment_url, + } + ) + ) + return + base_result = { - "environmentUrl": args.url.rstrip("/"), + "environmentUrl": environment_url, "vertical": args.vertical, "schemaName": WORKDAY_DA_CHILD_SCHEMAS[args.vertical], + "applicationName": WORKDAY_DA_PACKAGES[args.vertical][ + "applicationName" + ], } try: - schema_name = install_workday_da_extension(args.url, args.vertical) + schema_name = install_workday_da_extension( + environment_url, + args.vertical, + ) except ExtensionNotListedError as error: print( "WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:" 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 f78b83631..ae9b30ec8 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 @@ -6,27 +6,30 @@ not rephrase, add commentary, or tell the user what tools you are calling or wha files you are reading. This step completes **DA1.1** on the Workday connect checklist. It confirms the -DA Employee Self-Service HR base agent is installed, then installs the Workday -extension package against it — attempting an automated install first and -falling back to guided manual steps only if this tenant's Marketplace catalog -doesn't support it. The router must stop DA IT agents before this file is read. +ESS HR agent is available, then installs its Workday package. The router must +stop DA IT agents before this file is read. --- ## P1.0 — Check for the DA base agent, and install the extension if it's missing -Resolve the Dataverse environment that hosts the Workday extension: +Resolve the target environment automatically: 1. Use `.local/config.json` `dataverseEndpoint` when present (legacy Dataverse-backed workspace). 2. Otherwise read `.local/connect/workday-da/config.json` `sidecarDataverseEndpoint`. -3. If neither exists, ask the maker for the target Power Platform environment - URL shown in the Power Platform admin center. Explain that the MOS/native - agent remains in AgentBuilder while the Workday solution, connections, and - cloud flows require this Dataverse sidecar environment. Require an HTTPS - `*.crm*.dynamics.com` organization URL, show it back for confirmation, then - persist it as `sidecarDataverseEndpoint`. +3. If neither exists, read `.local/config.json` `environmentId` and run: + + ``` + python scripts/install_workday_da_extension.py --environment-id "{ENVIRONMENT_ID}" --vertical "hr" --resolve-only + ``` + + Parse `WORKDAY_DA_ENVIRONMENT_JSON:` and persist its `environmentUrl` as + `sidecarDataverseEndpoint`. +4. Only if automatic discovery fails, 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. Call the resolved value `WORKDAY_DATAVERSE_URL`. Never copy it into `.local/config.json`; that file's native `powerPlatformApiEndpoint` remains the @@ -78,11 +81,20 @@ environment is outside this lifecycle and must not affect DA1.1. ## P1.1 — Attempt an automated install +``` +python scripts/install_workday_da_extension.py --environment-id "{ENVIRONMENT_ID}" --vertical "hr" +``` + +Use the `--environment-id` form only when +`WORKDAY_DATAVERSE_URL` was resolved from that setup environment ID during +this run. If the URL came from `dataverseEndpoint` or a previously saved +`sidecarDataverseEndpoint`, run: + ``` python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" ``` -Run the command once. +Run the selected command once. Parse the script's JSON marker line: @@ -90,10 +102,9 @@ Parse the script's JSON marker line: already installed and the script confirmed it). Re-run `--checkpoint WD-DA-PKG-001` with the same `--connect-config`; proceed only when it reports `PASSED`. -- **`WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:`** → this tenant's Marketplace - catalog doesn't list the Workday extension package for the DA agent, so it - can't be installed automatically here. This is expected in some tenants — - it is not a failure. Continue to **P1.2** (manual install). +- **`WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:`** → the Workday package is not + available to the signed-in account through the environment's application + catalog. Continue to **P1.2**. - **`WORKDAY_DA_EXTENSION_INSTALL_TIMEOUT_JSON:`** → the install was started but didn't finish within the wait window. @@ -118,16 +129,9 @@ when it reports `PASSED`. **Message:** -I couldn't install the Workday extension package automatically in this -environment, so let's do it from the admin center — the same place you -installed the base Employee Self-Service agent: - -1. Open the [Microsoft 365 admin center](https://admin.microsoft.com) (or - AppSource, if that's where you installed the base agent). -2. Find the **Workday extension for Employee Self-Service HR** package. -3. Deploy the package to this environment. -4. Wait for the deployment to finish, then tell me it's done and I'll verify - it. +The Workday package could not be installed automatically. Ask a Power Platform +administrator to install **Workday HCM DA Connector (HR)** from AppSource in +this environment. Tell me when the installation finishes and I'll verify it. **End message.** diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py index ea91b92aa..3e43e56b3 100644 --- a/tests/scripts/test_install_workday_da_extension.py +++ b/tests/scripts/test_install_workday_da_extension.py @@ -17,9 +17,15 @@ class FakePPAdminClient: - def __init__(self, tenant_id, environment_id="env-123"): + def __init__( + self, + tenant_id, + environment_id="env-123", + environment=None, + ): self.tenant_id = tenant_id self.environment_id = environment_id + self.environment = environment self.authenticated = False def authenticate(self, *, include_flow=True): @@ -29,6 +35,9 @@ def authenticate(self, *, include_flow=True): def find_environment_id_by_dataverse_url(self, _env_url): return self.environment_id + def get_environment(self, _environment_id): + return self.environment + class FakePowerPlatformClient: def __init__(self, tenant_id, *, packages, install_result=None): @@ -57,7 +66,10 @@ def test_not_listed_when_marketplace_catalog_has_no_match(_mock_discover_tenant) powerplatform = FakePowerPlatformClient("tenant-123", packages=[]) - with pytest.raises(m.ExtensionNotListedError, match="msdyn_essdahrworkday"): + with pytest.raises( + m.ExtensionNotListedError, + match="msdyn_EssDAHRWorkdayHCM", + ): m.install_workday_da_extension( "https://org.crm.dynamics.com", "hr", @@ -75,7 +87,12 @@ def test_skips_install_when_hr_extension_already_installed(_mock_discover_tenant powerplatform = FakePowerPlatformClient( "tenant-123", - packages=[{"uniqueName": "msdyn_essdahrworkday", "state": "Installed"}], + packages=[ + { + "uniqueName": "msdyn_EssDAHRWorkdayHCM", + "state": "Installed", + } + ], ) schema = m.install_workday_da_extension( @@ -93,13 +110,13 @@ def test_skips_install_when_hr_extension_already_installed(_mock_discover_tenant def test_installs_and_polls_until_installed(_mock_discover_tenant): import install_workday_da_extension as m - schema_name = "msdyn_essdahrworkday" + application_name = "msdyn_EssDAHRWorkdayHCM" powerplatform = FakePowerPlatformClient( "tenant-123", packages=[ - [{"uniqueName": schema_name, "state": "None"}], - [{"uniqueName": schema_name, "state": "Installing"}], - [{"uniqueName": schema_name, "state": "Installed"}], + [{"uniqueName": application_name, "state": "None"}], + [{"uniqueName": application_name, "state": "Installing"}], + [{"uniqueName": application_name, "state": "Installed"}], ], install_result={"_operationId": "operation-123"}, ) @@ -113,21 +130,21 @@ def test_installs_and_polls_until_installed(_mock_discover_tenant): sleep=lambda _seconds: None, ) - assert schema == schema_name - assert powerplatform.install_calls == [("env-123", schema_name)] + assert schema == "msdyn_essdahrworkday" + assert powerplatform.install_calls == [("env-123", application_name)] @patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") def test_times_out_and_reports_last_status(_mock_discover_tenant): import install_workday_da_extension as m - schema_name = "msdyn_essdahrworkday" + application_name = "msdyn_EssDAHRWorkdayHCM" powerplatform = FakePowerPlatformClient( "tenant-123", packages=[ - [{"uniqueName": schema_name, "state": "None"}], - [{"uniqueName": schema_name, "state": "Installing"}], - [{"uniqueName": schema_name, "state": "Installing"}], + [{"uniqueName": application_name, "state": "None"}], + [{"uniqueName": application_name, "state": "Installing"}], + [{"uniqueName": application_name, "state": "Installing"}], ], install_result={"_operationId": "operation-123"}, ) @@ -167,10 +184,10 @@ def test_rejects_unsupported_it_vertical(_mock_discover_tenant): def test_reports_install_permission_failure(_mock_discover_tenant): import install_workday_da_extension as m - schema_name = "msdyn_essdahrworkday" + application_name = "msdyn_EssDAHRWorkdayHCM" powerplatform = FakePowerPlatformClient( "tenant-123", - packages=[{"uniqueName": schema_name, "state": "None"}], + packages=[{"uniqueName": application_name, "state": "None"}], install_result={"_error": "insufficient_permissions", "_status": 403}, ) @@ -181,3 +198,60 @@ def test_reports_install_permission_failure(_mock_discover_tenant): pp_admin_client_factory=FakePPAdminClient, powerplatform_client_factory=lambda _tenant: powerplatform, ) + + +def test_resolves_setup_environment_id_to_dataverse_url(): + import install_workday_da_extension as m + + environment = { + "properties": { + "linkedEnvironmentMetadata": { + "instanceUrl": "https://org.crm.dynamics.com/" + } + } + } + + result = m.resolve_dataverse_url( + "env-123", + pp_admin_client_factory=lambda tenant_id: FakePPAdminClient( + tenant_id, + environment=environment, + ), + ) + + assert result == "https://org.crm.dynamics.com" + + +def test_resolves_instance_api_url_when_instance_url_is_absent(): + import install_workday_da_extension as m + + environment = { + "properties": { + "linkedEnvironmentMetadata": { + "instanceApiUrl": "https://org.api.crm.dynamics.com/" + } + } + } + + result = m.resolve_dataverse_url( + "env-123", + pp_admin_client_factory=lambda tenant_id: FakePPAdminClient( + tenant_id, + environment=environment, + ), + ) + + assert result == "https://org.api.crm.dynamics.com" + + +def test_rejects_environment_without_dataverse(): + import install_workday_da_extension as m + + with pytest.raises(RuntimeError, match="does not have Dataverse"): + m.resolve_dataverse_url( + "env-123", + pp_admin_client_factory=lambda tenant_id: FakePPAdminClient( + tenant_id, + environment={"properties": {}}, + ), + ) From f0ee1d6fd6edecaf287d99d4de9449b3fa218648 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 17:28:58 -0700 Subject: [PATCH 14/17] fix(connect): use Workday runtime for MOS agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/flightcheck/checks/workday_da.py | 24 ++++++----- .../scripts/install_workday_da_extension.py | 41 ++++++++++++------- .../setup/workday-da/install-extension.md | 30 +++++++++++--- tests/flightcheck/checks/test_workday_da.py | 21 +++++----- .../test_install_workday_da_extension.py | 37 +++++++++++++---- 5 files changed, 105 insertions(+), 48 deletions(-) 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 096eb50bf..07d1fb334 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday_da.py @@ -22,7 +22,8 @@ _DA_HR_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedahr" _DA_IT_PARENT_SCHEMA = "msdyn_copilotforemployeeselfservicedait" -_DA_HR_WORKDAY_CHILD_SCHEMA = "msdyn_essdahrworkday" +_DA_HR_WORKDAY_CHILD_SCHEMA = "msdyn_EssDAHRWorkday" +_MOS_WORKDAY_RUNTIME_SCHEMA = "msdyn_EssWorkdayRuntime" _DA_HR_AGENT_SCHEMAS = { _DA_HR_PARENT_SCHEMA, "gptagent_copilotforemployeeselfservicehr", @@ -38,6 +39,7 @@ _DA_HR_PARENT_SCHEMA, _DA_IT_PARENT_SCHEMA, _DA_HR_WORKDAY_CHILD_SCHEMA, + _MOS_WORKDAY_RUNTIME_SCHEMA, ) _SOLN_FILTER = " or ".join( f"uniquename eq '{schema}'" for schema in _ALL_TRACKED_SCHEMAS @@ -167,24 +169,26 @@ def _check_workday_da_package_installed(runner) -> list[CheckResult]: ), )] - child = installed_names.get(_DA_HR_WORKDAY_CHILD_SCHEMA) + required_schema = ( + _MOS_WORKDAY_RUNTIME_SCHEMA + if active_schema == "gptagent_copilotforemployeeselfservicehr" + else _DA_HR_WORKDAY_CHILD_SCHEMA + ) + child = installed_names.get(required_schema.casefold()) if not child: return [_result( Status.FAILED.value, - "The Workday extension package is not installed for the ESS DA HR " - "agent.", + "The Workday package required by the ESS HR agent is not " + "installed.", remediation=( - "Open AppSource (or the Microsoft 365 admin center), find " - "the Workday extension for Employee Self-Service HR, and " - "deploy it to this environment — the same place you " - "installed the base agent. Wait for the install to finish, " - "then re-run this check." + "Run /connect workday to install the required Workday package " + "in this environment, then re-run this check." ), )] return [_result( Status.PASSED.value, - "ESS DA HR agent detected. DA Workday extension package installed: " + "ESS HR agent detected. Workday package installed: " f"{_describe_solution(child)}.", )] 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 a6b28969f..aa19c26e4 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -1,12 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Install the DA HR Workday AppSource application. +"""Install the Workday package required by the active ESS HR agent. Used by ``src/skills/setup/workday-da/install-extension.md`` (step DA1.1). -The installer uses the same AppSource application name as the bootstrap skill -and reports a distinct ``not-listed`` outcome when the signed-in account cannot -see the application in the selected environment. +Current MOS agents use the standalone Workday runtime package. Legacy DA agents +use the targeted DA HR Workday AppSource connector. """ from __future__ import annotations @@ -19,20 +18,21 @@ from auth import discover_tenant from flightcheck.checks.workday_da import ( _DA_HR_WORKDAY_CHILD_SCHEMA, + _MOS_WORKDAY_RUNTIME_SCHEMA, ) from flightcheck.powerplatform_client import PowerPlatformClient from flightcheck.pp_admin_client import PPAdminClient -WORKDAY_DA_PACKAGES = { - "hr": { +WORKDAY_PACKAGES = { + "runtime": { + "applicationName": _MOS_WORKDAY_RUNTIME_SCHEMA, + "schemaName": _MOS_WORKDAY_RUNTIME_SCHEMA, + }, + "legacy-da": { "applicationName": "msdyn_EssDAHRWorkdayHCM", "schemaName": _DA_HR_WORKDAY_CHILD_SCHEMA, }, } -WORKDAY_DA_CHILD_SCHEMAS = { - vertical: package["schemaName"] - for vertical, package in WORKDAY_DA_PACKAGES.items() -} INSTALLED_STATES = {"Installed", "TemplateInstalled"} IN_PROGRESS_STATES = { "InstallRequested", @@ -192,6 +192,7 @@ def install_workday_da_extension( env_url: str, vertical: str, *, + package_flavor: str = "runtime", pp_admin_client_factory=PPAdminClient, powerplatform_client_factory=PowerPlatformClient, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, @@ -201,7 +202,7 @@ def install_workday_da_extension( status_callback=lambda message: print(message, flush=True), installation_state_callback=lambda _status: None, ) -> str: - """Try to install the DA Workday extension package for one vertical. + """Try to install the required Workday package for one vertical. Returns the installed child solution's schema name on success. Raises ``ExtensionNotListedError`` when the Marketplace catalog does not return @@ -215,7 +216,9 @@ def install_workday_da_extension( "Workday integration with the ESS DA IT Agent is not supported " "in this release." ) - package_config = WORKDAY_DA_PACKAGES[vertical] + if package_flavor not in WORKDAY_PACKAGES: + raise ValueError(f"Unsupported Workday package flavor: {package_flavor}") + package_config = WORKDAY_PACKAGES[package_flavor] schema_name = package_config["schemaName"] application_name = package_config["applicationName"] tenant_id = discover_tenant(env_url) @@ -293,9 +296,15 @@ def main() -> None: parser.add_argument( "--vertical", required=True, - choices=sorted(WORKDAY_DA_PACKAGES), + choices=["hr"], help="DA vertical (this release supports hr only)", ) + parser.add_argument( + "--package-flavor", + choices=sorted(WORKDAY_PACKAGES), + default="runtime", + help="Package required by the active ESS agent architecture.", + ) parser.add_argument( "--resolve-only", action="store_true", @@ -328,8 +337,9 @@ def main() -> None: base_result = { "environmentUrl": environment_url, "vertical": args.vertical, - "schemaName": WORKDAY_DA_CHILD_SCHEMAS[args.vertical], - "applicationName": WORKDAY_DA_PACKAGES[args.vertical][ + "packageFlavor": args.package_flavor, + "schemaName": WORKDAY_PACKAGES[args.package_flavor]["schemaName"], + "applicationName": WORKDAY_PACKAGES[args.package_flavor][ "applicationName" ], } @@ -338,6 +348,7 @@ def main() -> None: schema_name = install_workday_da_extension( environment_url, args.vertical, + package_flavor=args.package_flavor, ) except ExtensionNotListedError as error: print( 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 ae9b30ec8..e5000d4b2 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 @@ -37,6 +37,14 @@ agent identity boundary. If `.local/connect/workday-da/config.json` does not yet exist, create it as an empty JSON object before the first checkpoint; if it exists, preserve all current fields. +Resolve the package flavor from the active agent schema: + +- `gptagent_copilotforemployeeselfservicehr` → `runtime` +- `msdyn_copilotforemployeeselfservicedahr` → `legacy-da` + +Call this value `PACKAGE_FLAVOR`. Stop if the active agent does not match one +of these supported HR schemas. + Run the checkpoint that reports both facts at once — whether a DA base agent exists, and whether Workday is already installed against it: @@ -66,9 +74,9 @@ environment is outside this lifecycle and must not affect DA1.1. Halt this skill entirely — do not proceed to DA-2 or DA-3. -- **`FAILED`** with "The Workday extension package is not installed for the - ESS DA HR agent" → the HR base agent is present but Workday isn't installed - yet. Continue to **P1.1**. +- **`FAILED`** with "The Workday package required by the ESS HR agent is not + installed" → the HR base agent is present but Workday isn't installed yet. + 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. @@ -82,7 +90,7 @@ environment is outside this lifecycle and must not affect DA1.1. ## P1.1 — Attempt an automated install ``` -python scripts/install_workday_da_extension.py --environment-id "{ENVIRONMENT_ID}" --vertical "hr" +python scripts/install_workday_da_extension.py --environment-id "{ENVIRONMENT_ID}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" ``` Use the `--environment-id` form only when @@ -91,7 +99,7 @@ this run. If the URL came from `dataverseEndpoint` or a previously saved `sidecarDataverseEndpoint`, run: ``` -python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" +python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" ``` Run the selected command once. @@ -127,6 +135,18 @@ when it reports `PASSED`. ## P1.2 — Guided manual install (fallback) +If `PACKAGE_FLAVOR` is `runtime`: + +**Message:** + +The Workday package could not be installed automatically. Ask a Power Platform +administrator to install **ESS Workday Runtime** from AppSource in this +environment. Tell me when the installation finishes and I'll verify it. + +**End message.** + +If `PACKAGE_FLAVOR` is `legacy-da`: + **Message:** The Workday package could not be installed automatically. Ask a Power Platform diff --git a/tests/flightcheck/checks/test_workday_da.py b/tests/flightcheck/checks/test_workday_da.py index 580aa411b..5b2c87b65 100644 --- a/tests/flightcheck/checks/test_workday_da.py +++ b/tests/flightcheck/checks/test_workday_da.py @@ -41,7 +41,8 @@ SOLN_FILTER = ( "uniquename eq 'msdyn_copilotforemployeeselfservicedahr' or " "uniquename eq 'msdyn_copilotforemployeeselfservicedait' or " - "uniquename eq 'msdyn_essdahrworkday'" + "uniquename eq 'msdyn_EssDAHRWorkday' or " + "uniquename eq 'msdyn_EssWorkdayRuntime'" ) SOLUTION_ID = "22222222-2222-2222-2222-222222222222" @@ -143,7 +144,7 @@ def test_failed_when_hr_base_agent_present_but_workday_child_missing( r = _check_workday_da_package_installed(runner)[0] assert r.status == "Failed" assert "HR" in r.result - assert "AppSource" in r.remediation + assert "/connect workday" in r.remediation @responses.activate @@ -162,13 +163,13 @@ def test_native_hr_agent_only_requires_child_in_sidecar( } _register_solutions( solutions=[ - _solution_record("msdyn_essdahrworkday", version="2.1.0.0"), + _solution_record("msdyn_EssWorkdayRuntime", version="2.1.0.0"), ] ) result = _check_workday_da_package_installed(runner)[0] assert result.status == "Passed" - assert "msdyn_essdahrworkday" in result.result + assert "msdyn_EssWorkdayRuntime" in result.result @responses.activate @@ -188,7 +189,7 @@ def test_native_it_active_agent_is_rejected( _register_solutions( solutions=[ _solution_record("msdyn_copilotforemployeeselfservicedahr"), - _solution_record("msdyn_essdahrworkday"), + _solution_record("msdyn_EssWorkdayRuntime"), ] ) @@ -215,12 +216,12 @@ def test_failed_when_only_it_base_agent_is_present( def test_passed_when_hr_workday_child_present(runner: _MinimalRunner) -> None: _register_solutions(solutions=[ _solution_record("msdyn_copilotforemployeeselfservicedahr"), - _solution_record("msdyn_essdahrworkday", version="2.0.0.1"), + _solution_record("msdyn_EssDAHRWorkday", version="2.0.0.1"), ]) r = _check_workday_da_package_installed(runner)[0] assert r.status == "Passed" - assert "msdyn_essdahrworkday" in r.result + assert "msdyn_EssDAHRWorkday" in r.result assert "2.0.0.1" in r.result # Principle: PASSED carries no remediation. assert r.remediation == "" @@ -234,13 +235,13 @@ def test_it_agent_does_not_block_supported_hr_package( _register_solutions(solutions=[ _solution_record("msdyn_copilotforemployeeselfservicedahr"), _solution_record("msdyn_copilotforemployeeselfservicedait"), - _solution_record("msdyn_essdahrworkday"), + _solution_record("msdyn_EssDAHRWorkday"), ]) r = _check_workday_da_package_installed(runner)[0] assert r.status == "Passed" - assert "ESS DA HR agent detected" in r.result - assert "msdyn_essdahrworkday" in r.result + assert "ESS HR agent detected" in r.result + assert "msdyn_EssDAHRWorkday" in r.result @responses.activate diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py index 3e43e56b3..2d1e716d4 100644 --- a/tests/scripts/test_install_workday_da_extension.py +++ b/tests/scripts/test_install_workday_da_extension.py @@ -68,7 +68,7 @@ def test_not_listed_when_marketplace_catalog_has_no_match(_mock_discover_tenant) with pytest.raises( m.ExtensionNotListedError, - match="msdyn_EssDAHRWorkdayHCM", + match="msdyn_EssWorkdayRuntime", ): m.install_workday_da_extension( "https://org.crm.dynamics.com", @@ -82,14 +82,14 @@ def test_not_listed_when_marketplace_catalog_has_no_match(_mock_discover_tenant) @patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_skips_install_when_hr_extension_already_installed(_mock_discover_tenant): +def test_skips_install_when_runtime_already_installed(_mock_discover_tenant): import install_workday_da_extension as m powerplatform = FakePowerPlatformClient( "tenant-123", packages=[ { - "uniqueName": "msdyn_EssDAHRWorkdayHCM", + "uniqueName": "msdyn_EssWorkdayRuntime", "state": "Installed", } ], @@ -102,7 +102,7 @@ def test_skips_install_when_hr_extension_already_installed(_mock_discover_tenant powerplatform_client_factory=lambda _tenant: powerplatform, ) - assert schema == "msdyn_essdahrworkday" + assert schema == "msdyn_EssWorkdayRuntime" assert powerplatform.install_calls == [] @@ -110,7 +110,7 @@ def test_skips_install_when_hr_extension_already_installed(_mock_discover_tenant def test_installs_and_polls_until_installed(_mock_discover_tenant): import install_workday_da_extension as m - application_name = "msdyn_EssDAHRWorkdayHCM" + application_name = "msdyn_EssWorkdayRuntime" powerplatform = FakePowerPlatformClient( "tenant-123", packages=[ @@ -130,7 +130,7 @@ def test_installs_and_polls_until_installed(_mock_discover_tenant): sleep=lambda _seconds: None, ) - assert schema == "msdyn_essdahrworkday" + assert schema == "msdyn_EssWorkdayRuntime" assert powerplatform.install_calls == [("env-123", application_name)] @@ -138,7 +138,7 @@ def test_installs_and_polls_until_installed(_mock_discover_tenant): def test_times_out_and_reports_last_status(_mock_discover_tenant): import install_workday_da_extension as m - application_name = "msdyn_EssDAHRWorkdayHCM" + application_name = "msdyn_EssWorkdayRuntime" powerplatform = FakePowerPlatformClient( "tenant-123", packages=[ @@ -184,7 +184,7 @@ def test_rejects_unsupported_it_vertical(_mock_discover_tenant): def test_reports_install_permission_failure(_mock_discover_tenant): import install_workday_da_extension as m - application_name = "msdyn_EssDAHRWorkdayHCM" + application_name = "msdyn_EssWorkdayRuntime" powerplatform = FakePowerPlatformClient( "tenant-123", packages=[{"uniqueName": application_name, "state": "None"}], @@ -200,6 +200,27 @@ def test_reports_install_permission_failure(_mock_discover_tenant): ) +@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") +def test_legacy_da_uses_targeted_appsource_connector(_mock_discover_tenant): + import install_workday_da_extension as m + + application_name = "msdyn_EssDAHRWorkdayHCM" + powerplatform = FakePowerPlatformClient( + "tenant-123", + packages=[{"uniqueName": application_name, "state": "Installed"}], + ) + + schema = m.install_workday_da_extension( + "https://org.crm.dynamics.com", + "hr", + package_flavor="legacy-da", + pp_admin_client_factory=FakePPAdminClient, + powerplatform_client_factory=lambda _tenant: powerplatform, + ) + + assert schema == "msdyn_EssDAHRWorkday" + + def test_resolves_setup_environment_id_to_dataverse_url(): import install_workday_da_extension as m From caccefb4841e3673d5bb4f0af60a55571c5ad263 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Mon, 21 Sep 2026 23:24:15 -0700 Subject: [PATCH 15/17] fix(connect): install Workday through ring-aware PAC Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/install_workday_da_extension.py | 492 +++++++----------- .../scripts/managed-pac.nuget.config | 7 + .../setup/workday-da/install-extension.md | 94 +--- .../test_install_workday_da_extension.py | 361 ++++++------- 4 files changed, 373 insertions(+), 581 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/managed-pac.nuget.config 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 aa19c26e4..ceb131980 100644 --- a/solutions/ess-maker-skills/scripts/install_workday_da_extension.py +++ b/solutions/ess-maker-skills/scripts/install_workday_da_extension.py @@ -1,27 +1,24 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Install the Workday package required by the active ESS HR agent. - -Used by ``src/skills/setup/workday-da/install-extension.md`` (step DA1.1). -Current MOS agents use the standalone Workday runtime package. Legacy DA agents -use the targeted DA HR Workday AppSource connector. -""" +"""Install the Workday package required by the active ESS HR agent.""" from __future__ import annotations import argparse import json +import os +from pathlib import Path +import re +import shutil +import subprocess import sys -import time -from auth import discover_tenant from flightcheck.checks.workday_da import ( _DA_HR_WORKDAY_CHILD_SCHEMA, _MOS_WORKDAY_RUNTIME_SCHEMA, ) -from flightcheck.powerplatform_client import PowerPlatformClient -from flightcheck.pp_admin_client import PPAdminClient + WORKDAY_PACKAGES = { "runtime": { @@ -33,271 +30,206 @@ "schemaName": _DA_HR_WORKDAY_CHILD_SCHEMA, }, } -INSTALLED_STATES = {"Installed", "TemplateInstalled"} -IN_PROGRESS_STATES = { - "InstallRequested", - "Installing", - "InstallScheduled", - "InstallRetrying", +CLOUD_FOR_RING = { + "preprod": "Preprod", + "prod": "Public", } -FAILED_STATES = {"InstallFailed"} -DEFAULT_TIMEOUT_SECONDS = 10 * 60 - - -class InstallationTimeoutError(RuntimeError): - """Raised when Power Platform does not finish installation in time.""" - - def __init__(self, unique_name: str, timeout_seconds: int): - self.unique_name = unique_name - self.timeout_seconds = timeout_seconds - super().__init__( - "Application installation did not finish within " - f"{timeout_seconds // 60} minutes." - ) - - -def _find_package(packages: list[dict], package_name: str) -> dict | None: - """Find an entitled application by unique or application name.""" - target = package_name.casefold() - for package in packages: - names = (package.get("uniqueName"), package.get("applicationName")) - if any( - isinstance(name, str) and name.casefold() == target - for name in names - ): - return package - return None - - -def _error_message(response: dict, default: str) -> str: - """Return a concise API error without dumping the full response.""" - error = ( - response.get("error") - or response.get("errorDetails") - or response.get("lastError") - or {} +_PROFILE_RE = re.compile(r"^\s*\[(\d+)\]\s*(\*)?\s*(.*)$") + + +class PacCliError(RuntimeError): + """Raised when PAC is unavailable or cannot complete an operation.""" + + +def resolve_pac_executable(environ=os.environ) -> Path: + """Prefer the shared managed PAC installation, then PAC on PATH.""" + local_app_data = environ.get("LOCALAPPDATA") + if local_app_data: + managed = Path(local_app_data) / "InternalTools" / "pac" / "pac.exe" + if managed.is_file(): + return managed + for candidate in ("pac", "pac.cmd", "pac.exe"): + resolved = shutil.which(candidate) + if resolved: + return Path(resolved) + raise PacCliError( + "PAC CLI is not installed. Install Microsoft Power Platform CLI, " + "then retry /connect workday." ) - if isinstance(error, dict): - message = error.get("message") or error.get("errorName") - if message: - return str(message) - message = response.get("statusMessage") - return str(message) if message else default -def _list_packages( - client: PowerPlatformClient, environment_id: str -) -> list[dict]: - packages = client.list_environment_application_packages(environment_id) - if isinstance(packages, dict) and packages.get("_error"): - raise RuntimeError( - "Your account cannot read Marketplace applications for this " - "environment. Use a Power Platform or Dynamics 365 administrator " - "account." +def _run(command, *, capture_output: bool, timeout: int): + """Run one PAC command without invoking a shell.""" + try: + return subprocess.run( + [str(part) for part in command], + capture_output=capture_output, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, ) - return packages + except subprocess.TimeoutExpired as exc: + raise PacCliError( + f"PAC command did not finish within {timeout // 60} minutes." + ) from exc + + +def _parse_profiles(output: str) -> list[dict]: + """Parse the profile index, active marker, and cloud from PAC output.""" + profiles = [] + for line in output.splitlines(): + match = _PROFILE_RE.match(line) + if not match: + continue + remainder = match.group(3) + cloud = next( + ( + token + for token in re.split(r"\s+", remainder) + if token.casefold() in {"public", "preprod"} + ), + None, + ) + if cloud: + profiles.append( + { + "index": match.group(1), + "active": bool(match.group(2)), + "cloud": cloud, + } + ) + return profiles -def _wait_for_install( - client: PowerPlatformClient, - environment_id: str, - unique_name: str, +def ensure_pac_auth( + pac_executable: Path, *, - timeout_seconds: int, - poll_interval_seconds: int, - sleep, - clock, - status_callback, + ring: str, + environment_url: str, + runner=_run, ) -> None: - """Poll the application-package collection until installation completes.""" - started_at = clock() - deadline = started_at + timeout_seconds - poll_number = 0 - - while True: - now = clock() - if now >= deadline: - break - poll_number += 1 - observed_status = "Unknown" - package = _find_package( - _list_packages(client, environment_id), - unique_name, - ) - if package: - state = package.get("state") - observed_status = state or "Unknown" - if state in INSTALLED_STATES: - return - if state in FAILED_STATES: - raise RuntimeError( - _error_message( - package, - f"Application installation ended with state {state}.", - ) - ) - status_callback( - "Installation status " - f"(poll {poll_number}, {int(now - started_at)}s elapsed): " - f"{observed_status}" + """Select or create a PAC profile for the requested Power Platform ring.""" + cloud = CLOUD_FOR_RING[ring] + listed = runner( + [pac_executable, "auth", "list"], + capture_output=True, + timeout=60, + ) + profiles = ( + _parse_profiles(listed.stdout or "") + if listed.returncode == 0 + else [] + ) + matching = [ + profile + for profile in profiles + if profile["cloud"].casefold() == cloud.casefold() + ] + active = [profile for profile in matching if profile["active"]] + if len(active) == 1: + return + if len(matching) == 1: + selected = runner( + [ + pac_executable, + "auth", + "select", + "--index", + matching[0]["index"], + ], + capture_output=True, + timeout=60, ) - sleep(poll_interval_seconds) - - raise InstallationTimeoutError(unique_name, timeout_seconds) - - -class ExtensionNotListedError(RuntimeError): - """The DA Workday extension is not in this tenant's Marketplace catalog. - - Not a failure — the calling skill step must treat this as "automation is - not available here" and fall back to the manual AppSource install, the - same guidance ``WD-DA-PKG-001`` (``checks/workday_da.py``) already gives. - """ - - def __init__(self, unique_name: str): - self.unique_name = unique_name - super().__init__( - f"'{unique_name}' was not found in this tenant's Marketplace " - "application catalog." + if selected.returncode != 0: + raise PacCliError("PAC could not select the required auth profile.") + return + if len(matching) > 1: + raise PacCliError( + f"Multiple PAC profiles exist for {cloud}. Select the correct " + "profile with 'pac auth select', then retry /connect workday." ) - -def resolve_dataverse_url( - environment_id: str, - *, - pp_admin_client_factory=PPAdminClient, -) -> str: - """Resolve the Dataverse URL for a setup-captured environment ID.""" - client = pp_admin_client_factory("organizations") - client.authenticate(include_flow=False) - environment = client.get_environment(environment_id) - if not isinstance(environment, dict) or environment.get("_error"): - raise RuntimeError( - "Could not read the selected Power Platform environment." - ) - linked = ( - environment.get("properties", {}) - .get("linkedEnvironmentMetadata", {}) - ) - for key in ("instanceUrl", "instanceApiUrl"): - value = linked.get(key) - if isinstance(value, str) and value.startswith("https://"): - return value.rstrip("/") - raise RuntimeError( - "The selected Power Platform environment does not have Dataverse." + command = [ + pac_executable, + "auth", + "create", + "--cloud", + cloud, + ] + if ring == "preprod": + command.extend(["--environment", environment_url]) + command.append("--deviceCode") + authenticated = runner( + command, + capture_output=False, + timeout=15 * 60, ) + if authenticated.returncode != 0: + raise PacCliError( + f"PAC authentication for {cloud} did not complete successfully." + ) -def install_workday_da_extension( - env_url: str, - vertical: str, +def install_workday_package( + environment_url: str, + package_flavor: str, *, - package_flavor: str = "runtime", - pp_admin_client_factory=PPAdminClient, - powerplatform_client_factory=PowerPlatformClient, - timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, - poll_interval_seconds: int = 20, - sleep=time.sleep, - clock=time.monotonic, - status_callback=lambda message: print(message, flush=True), - installation_state_callback=lambda _status: None, + ring: str, + pac_resolver=resolve_pac_executable, + runner=_run, ) -> str: - """Try to install the required Workday package for one vertical. - - Returns the installed child solution's schema name on success. Raises - ``ExtensionNotListedError`` when the Marketplace catalog does not return - the package at all (automation genuinely unavailable — not an error to - surface as a failure). Raises ``InstallationTimeoutError`` or - ``RuntimeError`` for other automation problems. - """ - env_url = env_url.rstrip("/") - if vertical != "hr": - raise ValueError( - "Workday integration with the ESS DA IT Agent is not supported " - "in this release." - ) + """Install one Workday AppSource package through the supported PAC flow.""" if package_flavor not in WORKDAY_PACKAGES: raise ValueError(f"Unsupported Workday package flavor: {package_flavor}") - package_config = WORKDAY_PACKAGES[package_flavor] - schema_name = package_config["schemaName"] - application_name = package_config["applicationName"] - tenant_id = discover_tenant(env_url) - - pp_admin = pp_admin_client_factory(tenant_id) - pp_admin.authenticate(include_flow=False) - environment_id = pp_admin.find_environment_id_by_dataverse_url(env_url) - if not environment_id: - raise RuntimeError( - "Could not resolve the selected environment's Power Platform ID." - ) - - client = powerplatform_client_factory(tenant_id) - client.authenticate() - package = _find_package( - _list_packages(client, environment_id), - application_name, + environment_url = environment_url.rstrip("/") + if not environment_url.startswith("https://"): + raise ValueError("The Power Platform environment URL must use HTTPS.") + + package = WORKDAY_PACKAGES[package_flavor] + pac_executable = pac_resolver() + ensure_pac_auth( + pac_executable, + ring=ring, + environment_url=environment_url, + runner=runner, ) - if not package: - raise ExtensionNotListedError(application_name) - - unique_name = package.get("uniqueName") or application_name - state = package.get("state") - if state in INSTALLED_STATES: - installation_state_callback("automatic-complete") - return schema_name - - if state in IN_PROGRESS_STATES: - installation_state_callback("installing") - else: - result = client.install_application_package(environment_id, unique_name) - if result.get("_error"): - raise RuntimeError( - "Your account cannot install Marketplace applications in this " - "environment. Use a Power Platform or Dynamics 365 " - "administrator account." - ) - last_state = result.get("lastOperation", {}).get("state") - if last_state in INSTALLED_STATES: - installation_state_callback("automatic-complete") - return schema_name - if last_state in FAILED_STATES: - raise RuntimeError( - _error_message(result.get("lastOperation", {}), "Installation failed.") - ) - installation_state_callback("installing") - - _wait_for_install( - client, - environment_id, - unique_name, - timeout_seconds=timeout_seconds, - poll_interval_seconds=poll_interval_seconds, - sleep=sleep, - clock=clock, - status_callback=status_callback, + installed = runner( + [ + pac_executable, + "application", + "install", + "--environment", + environment_url, + "--application-name", + package["applicationName"], + ], + capture_output=False, + timeout=20 * 60, ) - installation_state_callback("automatic-complete") - return schema_name + if installed.returncode != 0: + raise PacCliError( + "PAC could not install the Workday package. Review the PAC output " + "above, confirm environment access, and retry /connect workday." + ) + return package["schemaName"] def main() -> None: parser = argparse.ArgumentParser( - description=( - "Attempt an automated install of the DA Workday extension " - "package for one ESS vertical." - ) + description="Install the Workday package required by an ESS HR agent." ) - target = parser.add_mutually_exclusive_group(required=True) - target.add_argument("--url", help="Dataverse environment URL") - target.add_argument( - "--environment-id", - help="Power Platform environment ID captured during setup", + parser.add_argument( + "--url", + required=True, + help="Dataverse environment URL", ) parser.add_argument( "--vertical", required=True, choices=["hr"], - help="DA vertical (this release supports hr only)", + help="ESS vertical (this release supports hr only)", ) parser.add_argument( "--package-flavor", @@ -306,73 +238,41 @@ def main() -> None: help="Package required by the active ESS agent architecture.", ) parser.add_argument( - "--resolve-only", - action="store_true", - help="Resolve and print the Dataverse URL without installing.", + "--ring", + choices=sorted(CLOUD_FOR_RING), + default="prod", + help="Power Platform ring captured during setup.", ) args = parser.parse_args() - try: - environment_url = ( - args.url.rstrip("/") - if args.url - else resolve_dataverse_url(args.environment_id) - ) - except (OSError, RuntimeError, ValueError) as error: - print(f"ERROR: {error}", file=sys.stderr) - sys.exit(1) - - if args.resolve_only: - print( - "WORKDAY_DA_ENVIRONMENT_JSON:" - + json.dumps( - { - "environmentId": args.environment_id, - "environmentUrl": environment_url, - } - ) - ) - return - + package = WORKDAY_PACKAGES[args.package_flavor] base_result = { - "environmentUrl": environment_url, + "environmentUrl": args.url.rstrip("/"), "vertical": args.vertical, + "ring": args.ring, "packageFlavor": args.package_flavor, - "schemaName": WORKDAY_PACKAGES[args.package_flavor]["schemaName"], - "applicationName": WORKDAY_PACKAGES[args.package_flavor][ - "applicationName" - ], + "schemaName": package["schemaName"], + "applicationName": package["applicationName"], } - try: - schema_name = install_workday_da_extension( - environment_url, - args.vertical, - package_flavor=args.package_flavor, - ) - except ExtensionNotListedError as error: - print( - "WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:" - f"{json.dumps(base_result)}", - flush=True, + schema_name = install_workday_package( + args.url, + args.package_flavor, + ring=args.ring, ) - print(f"INFO: {error}", file=sys.stderr) - sys.exit(3) - except InstallationTimeoutError as error: - result = {**base_result, "timeoutMinutes": error.timeout_seconds // 60} + except (OSError, PacCliError, RuntimeError, ValueError) as error: print( - "WORKDAY_DA_EXTENSION_INSTALL_TIMEOUT_JSON:" - f"{json.dumps(result)}", + "WORKDAY_PACKAGE_INSTALL_FAILED_JSON:" + f"{json.dumps({**base_result, 'error': str(error)})}", flush=True, ) - print(f"ERROR: {error}", file=sys.stderr) - sys.exit(2) - except (OSError, RuntimeError, ValueError) as error: print(f"ERROR: {error}", file=sys.stderr) sys.exit(1) - result = {**base_result, "schemaName": schema_name} - print(f"INSTALLED_WORKDAY_DA_EXTENSION_JSON:{json.dumps(result)}") + print( + "INSTALLED_WORKDAY_DA_EXTENSION_JSON:" + f"{json.dumps({**base_result, 'schemaName': schema_name})}" + ) if __name__ == "__main__": diff --git a/solutions/ess-maker-skills/scripts/managed-pac.nuget.config b/solutions/ess-maker-skills/scripts/managed-pac.nuget.config new file mode 100644 index 000000000..cdb154146 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/managed-pac.nuget.config @@ -0,0 +1,7 @@ + + + + + + + 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 e5000d4b2..a31fc91ac 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 @@ -19,17 +19,10 @@ Resolve the target environment automatically: Dataverse-backed workspace). 2. Otherwise read `.local/connect/workday-da/config.json` `sidecarDataverseEndpoint`. -3. If neither exists, read `.local/config.json` `environmentId` and run: - - ``` - python scripts/install_workday_da_extension.py --environment-id "{ENVIRONMENT_ID}" --vertical "hr" --resolve-only - ``` - - Parse `WORKDAY_DA_ENVIRONMENT_JSON:` and persist its `environmentUrl` as - `sidecarDataverseEndpoint`. -4. Only if automatic discovery fails, show the environments available to the +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. + copy a URL when a selectable environment is available. Persist the selected + Dataverse URL as `sidecarDataverseEndpoint`. Call the resolved value `WORKDAY_DATAVERSE_URL`. Never copy it into `.local/config.json`; that file's native `powerPlatformApiEndpoint` remains the @@ -90,19 +83,13 @@ environment is outside this lifecycle and must not affect DA1.1. ## P1.1 — Attempt an automated install ``` -python scripts/install_workday_da_extension.py --environment-id "{ENVIRONMENT_ID}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" -``` - -Use the `--environment-id` form only when -`WORKDAY_DATAVERSE_URL` was resolved from that setup environment ID during -this run. If the URL came from `dataverseEndpoint` or a previously saved -`sidecarDataverseEndpoint`, run: - -``` -python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" +python scripts/install_workday_da_extension.py --url "{WORKDAY_DATAVERSE_URL}" --vertical "hr" --package-flavor "{PACKAGE_FLAVOR}" --ring "{RING}" ``` -Run the selected command once. +Read `RING` from canonical setup state `environment.ring`; use `prod` only for +legacy state with no recorded ring. Run the command once. The installer selects +or creates a PAC profile for that ring and PAC polls AppSource installation +internally. Parse the script's JSON marker line: @@ -110,54 +97,23 @@ Parse the script's JSON marker line: already installed and the script confirmed it). Re-run `--checkpoint WD-DA-PKG-001` with the same `--connect-config`; proceed only when it reports `PASSED`. -- **`WORKDAY_DA_EXTENSION_NOT_LISTED_JSON:`** → the Workday package is not - available to the signed-in account through the environment's application - catalog. Continue to **P1.2**. -- **`WORKDAY_DA_EXTENSION_INSTALL_TIMEOUT_JSON:`** → the install was started - but didn't finish within the wait window. - - **Message:** - - The Workday extension package install is still in progress in Power - Platform — this can take a few minutes. Check back shortly and I'll - re-verify it. - - **End message.** - - Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001` with - the same `--connect-config`. - Loop until it passes, or fall back to **P1.2** if the user reports the - install failed in the portal. -- Any other exit / error → show the error verbatim and continue to **P1.2** - (manual install) so the user isn't blocked by an automation failure. - ---- - -## P1.2 — Guided manual install (fallback) - -If `PACKAGE_FLAVOR` is `runtime`: - -**Message:** - -The Workday package could not be installed automatically. Ask a Power Platform -administrator to install **ESS Workday Runtime** from AppSource in this -environment. Tell me when the installation finishes and I'll verify it. - -**End message.** - -If `PACKAGE_FLAVOR` is `legacy-da`: - -**Message:** - -The Workday package could not be installed automatically. Ask a Power Platform -administrator to install **Workday HCM DA Connector (HR)** from AppSource in -this environment. Tell me when the installation finishes and I'll verify it. - -**End message.** - -Wait for the user to confirm, then re-run `--checkpoint WD-DA-PKG-001` with the -same `--connect-config`. Loop until it passes. While it is not yet passing, -keep DA1.1 `in-progress`. +- **`WORKDAY_PACKAGE_INSTALL_FAILED_JSON:`** → show its concise `error` value + and stop with DA1.1 `in-progress`. Do not claim the package needs a manual + AppSource installation. PAC's output is the source of truth: + - If PAC CLI is missing, ask whether the maker wants the kit to install the + current-user managed copy. If approved, run: + + ```powershell + dotnet tool install --tool-path "$env:LOCALAPPDATA\InternalTools\pac" --interactive --verbosity n --configfile "scripts\managed-pac.nuget.config" Microsoft.PowerApps.CLI.Tool + ``` + + If the tool is already present but needs repair or update, run the same + command with `update` instead of `install`. Then rerun P1.1. + - If PAC starts device-code authentication, wait for it to finish. + - If multiple profiles exist for the required ring, ask the maker to select + the intended profile with `pac auth select`, then retry. + - For permission or package-availability errors, show PAC's output and ask + the maker to correct that exact issue before retrying. --- diff --git a/tests/scripts/test_install_workday_da_extension.py b/tests/scripts/test_install_workday_da_extension.py index 2d1e716d4..1a6e492e2 100644 --- a/tests/scripts/test_install_workday_da_extension.py +++ b/tests/scripts/test_install_workday_da_extension.py @@ -1,278 +1,207 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Tests for the DA Workday extension package installer. - -Mirrors the mocking pattern in ``tests/scripts/test_install_ess_agent.py``: -fake ``PPAdminClient``/``PowerPlatformClient`` factories stand in for the -real Power Platform REST APIs, and ``discover_tenant`` is patched so no -network call is made. -""" +"""Tests for the PAC-based Workday package installer.""" from __future__ import annotations -from unittest.mock import patch +from pathlib import Path +from types import SimpleNamespace import pytest -class FakePPAdminClient: - def __init__( - self, - tenant_id, - environment_id="env-123", - environment=None, - ): - self.tenant_id = tenant_id - self.environment_id = environment_id - self.environment = environment - self.authenticated = False - - def authenticate(self, *, include_flow=True): - self.authenticated = True - self.include_flow = include_flow - - def find_environment_id_by_dataverse_url(self, _env_url): - return self.environment_id - - def get_environment(self, _environment_id): - return self.environment - - -class FakePowerPlatformClient: - def __init__(self, tenant_id, *, packages, install_result=None): - self.tenant_id = tenant_id - self.packages = list(packages) - self.install_result = install_result or {} - self.authenticated = False - self.install_calls = [] - - def authenticate(self): - self.authenticated = True - - def list_environment_application_packages(self, _environment_id): - if self.packages and isinstance(self.packages[0], list): - return self.packages.pop(0) - return self.packages - - def install_application_package(self, environment_id, unique_name): - self.install_calls.append((environment_id, unique_name)) - return self.install_result +def _result(returncode=0, stdout="", stderr=""): + return SimpleNamespace( + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) -@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_not_listed_when_marketplace_catalog_has_no_match(_mock_discover_tenant): +def test_parse_profiles_reads_cloud_and_active_marker(): import install_workday_da_extension as m - powerplatform = FakePowerPlatformClient("tenant-123", packages=[]) - - with pytest.raises( - m.ExtensionNotListedError, - match="msdyn_EssWorkdayRuntime", - ): - m.install_workday_da_extension( - "https://org.crm.dynamics.com", - "hr", - pp_admin_client_factory=FakePPAdminClient, - powerplatform_client_factory=lambda _tenant: powerplatform, - ) + profiles = m._parse_profiles( + "[1] user@contoso.com Public\n" + "[2] * user@contoso.com Preprod https://org.crm10.dynamics.com\n" + ) - # Never attempts an install call against a package that isn't listed. - assert powerplatform.install_calls == [] + assert profiles == [ + {"index": "1", "active": False, "cloud": "Public"}, + {"index": "2", "active": True, "cloud": "Preprod"}, + ] -@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_skips_install_when_runtime_already_installed(_mock_discover_tenant): +def test_runtime_install_uses_preprod_pac_profile_and_application(): import install_workday_da_extension as m - powerplatform = FakePowerPlatformClient( - "tenant-123", - packages=[ - { - "uniqueName": "msdyn_EssWorkdayRuntime", - "state": "Installed", - } - ], - ) - - schema = m.install_workday_da_extension( - "https://org.crm.dynamics.com", - "hr", - pp_admin_client_factory=FakePPAdminClient, - powerplatform_client_factory=lambda _tenant: powerplatform, + calls = [] + + def runner(command, *, capture_output, timeout): + calls.append(([str(part) for part in command], capture_output, timeout)) + if command[1:3] == ["auth", "list"]: + return _result( + stdout=( + "[1] * user@contoso.com Preprod " + "https://org.crm10.dynamics.com\n" + ) + ) + return _result() + + schema = m.install_workday_package( + "https://org.crm10.dynamics.com", + "runtime", + ring="preprod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, ) assert schema == "msdyn_EssWorkdayRuntime" - assert powerplatform.install_calls == [] - - -@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_installs_and_polls_until_installed(_mock_discover_tenant): + assert calls[-1][0] == [ + "pac.exe", + "application", + "install", + "--environment", + "https://org.crm10.dynamics.com", + "--application-name", + "msdyn_EssWorkdayRuntime", + ] + assert calls[-1][1] is False + + +def test_preprod_auth_uses_environment_anchor_when_profile_is_missing(): import install_workday_da_extension as m - application_name = "msdyn_EssWorkdayRuntime" - powerplatform = FakePowerPlatformClient( - "tenant-123", - packages=[ - [{"uniqueName": application_name, "state": "None"}], - [{"uniqueName": application_name, "state": "Installing"}], - [{"uniqueName": application_name, "state": "Installed"}], - ], - install_result={"_operationId": "operation-123"}, - ) + calls = [] - schema = m.install_workday_da_extension( - "https://org.crm.dynamics.com", - "hr", - pp_admin_client_factory=FakePPAdminClient, - powerplatform_client_factory=lambda _tenant: powerplatform, - poll_interval_seconds=0, - sleep=lambda _seconds: None, + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + return _result() + + m.install_workday_package( + "https://org.crm10.dynamics.com", + "runtime", + ring="preprod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, ) - assert schema == "msdyn_EssWorkdayRuntime" - assert powerplatform.install_calls == [("env-123", application_name)] + assert calls[1] == [ + "pac.exe", + "auth", + "create", + "--cloud", + "Preprod", + "--environment", + "https://org.crm10.dynamics.com", + "--deviceCode", + ] -@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_times_out_and_reports_last_status(_mock_discover_tenant): +def test_selects_single_inactive_profile_for_requested_ring(): import install_workday_da_extension as m - application_name = "msdyn_EssWorkdayRuntime" - powerplatform = FakePowerPlatformClient( - "tenant-123", - packages=[ - [{"uniqueName": application_name, "state": "None"}], - [{"uniqueName": application_name, "state": "Installing"}], - [{"uniqueName": application_name, "state": "Installing"}], - ], - install_result={"_operationId": "operation-123"}, - ) - now = [0] - - def sleep(seconds): - now[0] += seconds - - with pytest.raises(m.InstallationTimeoutError, match="10 minutes"): - m.install_workday_da_extension( - "https://org.crm.dynamics.com", - "hr", - pp_admin_client_factory=FakePPAdminClient, - powerplatform_client_factory=lambda _tenant: powerplatform, - poll_interval_seconds=300, - sleep=sleep, - clock=lambda: now[0], - ) + calls = [] + def runner(command, *, capture_output, timeout): + calls.append([str(part) for part in command]) + if command[1:3] == ["auth", "list"]: + return _result(stdout="[4] user@contoso.com Public\n") + return _result() -@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_rejects_unsupported_it_vertical(_mock_discover_tenant): - import install_workday_da_extension as m + m.ensure_pac_auth( + Path("pac.exe"), + ring="prod", + environment_url="https://org.crm.dynamics.com", + runner=runner, + ) - with pytest.raises(ValueError, match="ESS DA IT Agent is not supported"): - m.install_workday_da_extension( - "https://org.crm.dynamics.com", - "it", - pp_admin_client_factory=FakePPAdminClient, - powerplatform_client_factory=lambda _tenant: FakePowerPlatformClient( - "tenant-123", packages=[] - ), - ) + assert calls[-1] == [ + "pac.exe", + "auth", + "select", + "--index", + "4", + ] -@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_reports_install_permission_failure(_mock_discover_tenant): +def test_rejects_ambiguous_profiles_for_requested_ring(): import install_workday_da_extension as m - application_name = "msdyn_EssWorkdayRuntime" - powerplatform = FakePowerPlatformClient( - "tenant-123", - packages=[{"uniqueName": application_name, "state": "None"}], - install_result={"_error": "insufficient_permissions", "_status": 403}, - ) + def runner(command, *, capture_output, timeout): + return _result( + stdout=( + "[1] user1@contoso.com Preprod\n" + "[2] user2@contoso.com Preprod\n" + ) + ) - with pytest.raises(RuntimeError, match="cannot install"): - m.install_workday_da_extension( - "https://org.crm.dynamics.com", - "hr", - pp_admin_client_factory=FakePPAdminClient, - powerplatform_client_factory=lambda _tenant: powerplatform, + with pytest.raises(m.PacCliError, match="Multiple PAC profiles"): + m.ensure_pac_auth( + Path("pac.exe"), + ring="preprod", + environment_url="https://org.crm10.dynamics.com", + runner=runner, ) -@patch("install_workday_da_extension.discover_tenant", return_value="tenant-123") -def test_legacy_da_uses_targeted_appsource_connector(_mock_discover_tenant): +def test_legacy_da_uses_targeted_appsource_application(): import install_workday_da_extension as m - application_name = "msdyn_EssDAHRWorkdayHCM" - powerplatform = FakePowerPlatformClient( - "tenant-123", - packages=[{"uniqueName": application_name, "state": "Installed"}], - ) + 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] * user@contoso.com Public\n") + return _result() - schema = m.install_workday_da_extension( + schema = m.install_workday_package( "https://org.crm.dynamics.com", - "hr", - package_flavor="legacy-da", - pp_admin_client_factory=FakePPAdminClient, - powerplatform_client_factory=lambda _tenant: powerplatform, + "legacy-da", + ring="prod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, ) assert schema == "msdyn_EssDAHRWorkday" + assert calls[-1][-1] == "msdyn_EssDAHRWorkdayHCM" -def test_resolves_setup_environment_id_to_dataverse_url(): +def test_surfaces_pac_install_failure(): import install_workday_da_extension as m - environment = { - "properties": { - "linkedEnvironmentMetadata": { - "instanceUrl": "https://org.crm.dynamics.com/" - } - } - } - - result = m.resolve_dataverse_url( - "env-123", - pp_admin_client_factory=lambda tenant_id: FakePPAdminClient( - tenant_id, - environment=environment, - ), - ) + def runner(command, *, capture_output, timeout): + if command[1:3] == ["auth", "list"]: + return _result(stdout="[1] * user@contoso.com Public\n") + return _result(returncode=1) - assert result == "https://org.crm.dynamics.com" + with pytest.raises(m.PacCliError, match="could not install"): + m.install_workday_package( + "https://org.crm.dynamics.com", + "runtime", + ring="prod", + pac_resolver=lambda: Path("pac.exe"), + runner=runner, + ) -def test_resolves_instance_api_url_when_instance_url_is_absent(): +def test_rejects_non_https_environment_url(): import install_workday_da_extension as m - environment = { - "properties": { - "linkedEnvironmentMetadata": { - "instanceApiUrl": "https://org.api.crm.dynamics.com/" - } - } - } - - result = m.resolve_dataverse_url( - "env-123", - pp_admin_client_factory=lambda tenant_id: FakePPAdminClient( - tenant_id, - environment=environment, - ), - ) - - assert result == "https://org.api.crm.dynamics.com" + with pytest.raises(ValueError, match="must use HTTPS"): + m.install_workday_package( + "http://org.crm.dynamics.com", + "runtime", + ring="prod", + ) -def test_rejects_environment_without_dataverse(): +def test_resolve_pac_reports_missing_cli(monkeypatch): import install_workday_da_extension as m - with pytest.raises(RuntimeError, match="does not have Dataverse"): - m.resolve_dataverse_url( - "env-123", - pp_admin_client_factory=lambda tenant_id: FakePPAdminClient( - tenant_id, - environment={"properties": {}}, - ), - ) + monkeypatch.setattr(m.shutil, "which", lambda _candidate: None) + + with pytest.raises(m.PacCliError, match="PAC CLI is not installed"): + m.resolve_pac_executable(environ={}) From bf30a9d9b3704171d3a60476b4d1790b5c1bad84 Mon Sep 17 00:00:00 2001 From: is-goutham Date: Tue, 22 Sep 2026 00:41:15 -0700 Subject: [PATCH 16/17] fix(connect): harden Workday runtime setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../alm/Enable-CosmosDAFlowAuthorization.ps1 | 48 +++++++- .../scripts/alm/get_dataverse_token.py | 29 +++++ .../scripts/flightcheck/checks/workday.py | 45 +++++++- .../scripts/flightcheck/cli.py | 16 ++- .../workday-da/configure-power-platform.md | 105 ++++++++++++------ .../setup/workday-da/provision-entra-app.md | 35 +++++- .../checks/test_workday_package.py | 27 +++++ .../checks/test_workday_saml_certificate.py | 32 ++++++ tests/flightcheck/test_check_roles.py | 16 +++ tests/setup/test_setup_router.py | 16 +++ 10 files changed, 325 insertions(+), 44 deletions(-) create mode 100644 solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py diff --git a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 index faa5c5bc3..00f2e6141 100644 --- a/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 +++ b/solutions/ess-maker-skills/scripts/alm/Enable-CosmosDAFlowAuthorization.ps1 @@ -79,15 +79,57 @@ function Write-Reuse { param([string]$m) Write-Host " [reuse] $m" -Foreground function Write-Create { param([string]$m) Write-Host " [create] $m" -ForegroundColor Yellow } function Write-Fail { param([string]$m) Write-Host " [FAIL] $m" -ForegroundColor Red } +function Test-DataverseToken { + param( + [Parameter(Mandatory = $true)][string]$Resource, + [Parameter(Mandatory = $true)][string]$Token + ) + try { + Invoke-RestMethod ` + -Method GET ` + -Uri "$($Resource.TrimEnd('/'))/api/data/v9.1/WhoAmI" ` + -Headers @{ Authorization = "******"; Accept = 'application/json' } ` + -ErrorAction Stop | Out-Null + return $true + } catch { + if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 401) { + return $false + } + throw + } +} + function Get-DataverseToken { param([string]$Resource) + $tok = $null try { $tok = az account get-access-token --resource $Resource --query accessToken -o tsv 2>$null } catch { - throw "Azure CLI token acquisition failed. Run 'az login' and ensure the account has access to $Resource." + $tok = $null + } + if (-not [string]::IsNullOrWhiteSpace($tok) -and (Test-DataverseToken -Resource $Resource -Token $tok)) { + return $tok + } + + Write-Host " Azure CLI token was unavailable or rejected; using the kit's Dataverse sign-in." -ForegroundColor Yellow + $helper = Join-Path $PSScriptRoot 'get_dataverse_token.py' + $python = Get-Command python -ErrorAction SilentlyContinue + if (-not $python -or -not (Test-Path $helper)) { + throw "Could not acquire a valid Dataverse token. Install Python, then run the kit setup or sign in with an account that can access $Resource." + } + + $output = @(& $python.Source $helper --environment $Resource 2>&1) + if ($LASTEXITCODE -ne 0) { + $safeError = ($output | Where-Object { $_ -notmatch '^ESS_DATAVERSE_TOKEN=' }) -join [Environment]::NewLine + throw "Kit Dataverse authentication failed: $safeError" + } + $marker = $output | Where-Object { $_ -match '^ESS_DATAVERSE_TOKEN=' } | Select-Object -Last 1 + if (-not $marker) { + throw "Kit Dataverse authentication did not return an access token." } + $tok = ([string]$marker).Substring('ESS_DATAVERSE_TOKEN='.Length) if ([string]::IsNullOrWhiteSpace($tok)) { - throw "Could not acquire a Dataverse token for $Resource. Check 'az account show' and that the correct subscription/tenant is selected." + throw "Kit Dataverse authentication returned an empty access token for $Resource." } return $tok } @@ -104,6 +146,7 @@ function Invoke-Dv { Accept = 'application/json' 'OData-MaxVersion' = '4.0' 'OData-Version' = '4.0' + Prefer = 'return=representation' } try { if ($null -ne $Body) { @@ -184,7 +227,6 @@ if ($teamExisting.Count -gt 0) { if (-not $AdministratorId) { # The team administrator must hold System Administrator, otherwise team creation fails # with 0x80041d0a "The team administrator does not have privilege read team." - $admins = (Invoke-Dv -Path ("systemuserrolescollection?`$select=systemuserid&`$top=0")) 2>$null $roleQ = 'roles?$select=roleid&$filter=name eq ''System Administrator''' $role = (Invoke-Dv -Path $roleQ).value if (-not $role) { throw 'Could not locate the System Administrator role. Pass -AdministratorId explicitly.' } diff --git a/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py new file mode 100644 index 000000000..877292429 --- /dev/null +++ b/solutions/ess-maker-skills/scripts/alm/get_dataverse_token.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Emit a validated Dataverse token for the flow-authorization PowerShell script.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS_DIR)) + +import auth # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--environment", required=True) + args = parser.parse_args() + + token = auth.authenticate(args.environment.rstrip("/")) + print(f"ESS_DATAVERSE_TOKEN={token}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py index c229cebea..d35269db4 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/checks/workday.py @@ -1179,7 +1179,14 @@ def _check_package_flavor(runner, *, wd_flows: list) -> list[CheckResult]: )) return results - workday_refs = [r for r in refs if _is_workday_soap_connector(r.get("connectorid"))] + workday_refs = [ + r + for r in refs + if _is_workday_soap_connector(r.get("connectorid")) + and not _AGENT_CONNECTION_REF_RE.search( + r.get("connectionreferencelogicalname") or "" + ) + ] runner._workday_connection_refs = workday_refs # Classify each Workday row's suffix (some may not match the @@ -2753,9 +2760,27 @@ def _select_active_workday_cert( return (active, others) -def _format_cert_detail_line(cert: dict, now: datetime) -> str: +def _format_preferred_thumbprint(preferred_thumbprint: str | None) -> str | None: + """Format Graph's colon-free SHA-1 preferred signing thumbprint.""" + normalized = (preferred_thumbprint or "").strip().replace(":", "") + if not re.fullmatch(r"[0-9A-Fa-f]{40}", normalized): + return None + return ":".join( + normalized[index:index + 2].upper() + for index in range(0, len(normalized), 2) + ) + + +def _format_cert_detail_line( + cert: dict, + now: datetime, + *, + thumbprint_override: str | None = None, +) -> str: """Render one cert group as a one-line summary for result text.""" - display, _ok = _format_cert_thumbprint(cert["customKeyIdentifier"]) + display = thumbprint_override + if display is None: + display, _ok = _format_cert_thumbprint(cert["customKeyIdentifier"]) end = cert["end"] if end is None: expiry_str = "NotAfter=(unknown)" @@ -3009,7 +3034,19 @@ def _check_saml_certificate_health(runner) -> list[CheckResult]: (c["end"] is not None and c["end"] < now) for c in cert_groups ) - cert_line = _format_cert_detail_line(active, now) + preferred_display = _format_preferred_thumbprint( + sp.get("preferredTokenSigningKeyThumbprint") + ) + # Graph tenants can surface a non-SHA-1 customKeyIdentifier even though + # preferredTokenSigningKeyThumbprint is the authoritative SHA-1 value. + # With one logical certificate there is no ambiguity, so show the + # preferred thumbprint rather than incorrectly calling the key malformed. + active_thumbprint = preferred_display if len(cert_groups) == 1 else None + cert_line = _format_cert_detail_line( + active, + now, + thumbprint_override=active_thumbprint, + ) rollover_lines = [ f" rollover: {_format_cert_detail_line(c, now)}" for c in others diff --git a/solutions/ess-maker-skills/scripts/flightcheck/cli.py b/solutions/ess-maker-skills/scripts/flightcheck/cli.py index 3f5a8da9d..069a6cdf2 100644 --- a/solutions/ess-maker-skills/scripts/flightcheck/cli.py +++ b/solutions/ess-maker-skills/scripts/flightcheck/cli.py @@ -1726,8 +1726,8 @@ def _print_prioritized_summary(result, *, verbose_manual=False): 3. ACTION REQUIRED — full per-row detail (Failed / Error). 4. NEEDS MANUAL VERIFICATION — one line per row (Warning / Manual / NotConfigured). - 5. PASSED — count only (includes Passed + Skipped); point to - report.html for the list. + 5. SKIPPED — count only, when present. + 6. PASSED — count only; point to report.html for the list. The goal is for an operator scanning the terminal to see, in order: am I OK? what must I fix? what must I verify? — without @@ -1736,7 +1736,9 @@ def _print_prioritized_summary(result, *, verbose_manual=False): buckets = bucket_results(result.results) action = buckets[BUCKET_ACTION] manual = buckets[BUCKET_MANUAL] - passed = buckets[BUCKET_PASSED] + completed = buckets[BUCKET_PASSED] + passed = [r for r in completed if r.status == Status.PASSED.value] + skipped = [r for r in completed if r.status == Status.SKIPPED.value] print() print("=" * 64) @@ -1823,7 +1825,13 @@ def _print_prioritized_summary(result, *, verbose_manual=False): print(" (Open report.html for the full result + verification " "steps.)") - # Section 3 — PASSED (count only; the operator doesn't need to + if skipped: + print() + print(f" SKIPPED ({len(skipped)})") + print(" " + "-" * 62) + print(" See report.html for the checks that could not be evaluated.") + + # Section 4 — PASSED (count only; the operator doesn't need to # scroll past 200+ green rows to find what needs their attention). print() print(f" PASSED ({len(passed)})") 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 9d383322c..305441e5a 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 @@ -9,12 +9,19 @@ 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. -The two required connection references are: +The two required connection references depend on the package flavor installed +in DA-1: -| Connection | Logical name | -| --- | --- | -| Workday OAuthUser | `new_sharedworkdaysoap_ff0df` | -| Microsoft Dataverse | `msviess_sharedcommondataserviceforapps_92b66` | +| Package | Connection | Logical name | +| --- | --- | --- | +| MOS runtime (`msdyn_EssWorkdayRuntime`) | Workday OAuthUser | `msdyn_sharedworkdaysoap_workdayruntime` | +| MOS runtime (`msdyn_EssWorkdayRuntime`) | Microsoft Dataverse | `msdyn_sharedcommondataserviceforapps_workdayruntime` | +| Legacy DA (`msdyn_EssDAHRWorkdayHCM`) | Workday OAuthUser | `new_sharedworkdaysoap_ff0df` | +| Legacy DA (`msdyn_EssDAHRWorkdayHCM`) | Microsoft Dataverse | `msviess_sharedcommondataserviceforapps_92b66` | + +Resolve the rows from the installed solution and connector IDs before showing +portal instructions. Do not report a missing connection merely because a +logical name from the other package flavor is absent. Never reuse Dev connection IDs, bot IDs, or workflow IDs in another environment. @@ -42,12 +49,17 @@ Persist `installPath: "simplified"` and, for an upgrade, also persist **Message:** -Now we'll create or reconnect the Workday connection used on behalf of each -signed-in employee. +Now we'll create the Workday connection used on behalf of each signed-in +employee, then attach it to the installed Workday solution. -In the Power Apps maker portal, select this environment and open the installed -Workday solution. Configure the **OAuthUser** connection reference using a -Workday connection with **Microsoft Entra ID Integrated** authentication. +1. In the Power Apps maker portal, select this environment. +2. Open **Connections** → **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. +4. After the connection shows **Connected**, open **Solutions** → the installed + Workday runtime solution → **Connection references**. +5. Open **Workday OAuth Runtime**, select the connection you just created, and + save. Reopen it once to confirm the selection was retained. Use the values captured earlier: @@ -63,7 +75,10 @@ open and configure each reference separately. **End message.** -Ask the maker to confirm that OAuthUser is connected with the values above. +If no Workday connection exists yet, do not ask the maker to confirm the +reference binding—guide the connection creation first. Ask the maker to confirm +that OAuthUser is connected with the values above and that the +`connectionreference` row has a non-empty `connectionid`. Record DA4.1 with `GATE="manual"`, `ACK=true`, and evidence describing the connection name and environment. If it is not connected, leave the row `in-progress`. @@ -72,21 +87,36 @@ connection name and environment. If it is not connected, leave the row **Message:** -In the same Workday solution, configure the **Microsoft Dataverse** connection -reference with an active connection owned by your maker account. Confirm it -targets this environment, not a Dev connection copied from another environment. +1. In Power Apps **Connections**, create a **Microsoft Dataverse** connection + with your maker account if this environment does not already have one. +2. Return to the installed Workday runtime solution → **Connection references**. +3. Open **Microsoft Dataverse - Workday Runtime**, select that Dataverse + connection, and save. +4. Reopen the reference and confirm the selection was retained and targets this + environment, not a Dev connection copied from another environment. **End message.** Ask for confirmation and record DA4.2 as a manual row with the connection name -and environment in the evidence. +and environment in the evidence. Verify its `connectionreference` row has a +non-empty `connectionid`; if it remains empty, do not advance to flow activation. ## DA4.3 — Share parameters and repair stale connections **Message:** -Open the Workday connection's **Connection parameters** page and turn on -**Allow permission to share parameters**, then save. +Open the active agent's **Copilot Studio → Settings → Connection settings** +page: + +`https://{CPS_HOST}/environments/{ENV_ID}/copilots/{BOT_ID}/da-settings/connectionSettings` + +For each Workday flow entry: + +1. Click the entry → **Connect**. +2. Select the Workday connection created in DA4.1 and submit. +3. Under **Manage**, click **See details**. +4. Open the **Connection parameters** tab. +5. Turn on **Allow permission to share parameters** and save. If the parameter values appear empty, turn the setting off and save, turn it back on and save again, then confirm the REST, SOAP, token, client, and resource @@ -98,8 +128,11 @@ asked to authorize again on their next Workday request. **End message.** -Require explicit confirmation that parameter sharing is enabled, the fields -remain populated, and the connection is connected. Record DA4.3 as manual. +This setting is agent/runtime wiring; 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. Record DA4.3 as +manual. ## DA4.4 — Confirm connection binding @@ -111,12 +144,18 @@ connection-parameter configuration. Confirm: 3. The required `connectionparametersetconfig` values are populated for the target environment. 4. No reference points to a connection from a different environment or user. +5. The Copilot Studio connection-settings entries are wired separately per + DA4.3; solution-level Dataverse binding does not replace that runtime step. The authorization script does not perform this step. Require explicit confirmation and record DA4.4 as manual. ## DA4.5 — Turn on the Workday cloud flows +Do not activate flows until DA4.1–DA4.4 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 @@ -143,7 +182,10 @@ Use the checked-in authorization script: Execute this PowerShell file directly. Do not translate, regenerate, or replace it with Python. PowerShell 7 is preferred; Windows PowerShell 5.1 is also -supported by the script syntax. +supported by the script syntax. The script validates the Azure CLI Dataverse +token before use. If that token is rejected (including PPE environments), it +automatically reuses the kit's Dataverse authentication cache and opens the +standard kit sign-in only when a refresh is required. Resolve parameters instead of asking the maker to paste GUIDs: @@ -165,9 +207,9 @@ delegated-authorization and team lookups documented by the script: - exactly one MCSBot delegated authorization and one linked Access team already exist for the bot → the script may verify/reuse them and add missing workflow shares; -- no authorization or team exists → stop before apply. The checked-in source - version requires a Microsoft-owned update that requests Dataverse POST - representations before it can safely create and bind those records; +- no authorization or team exists → the script may create them. Its Dataverse + writes request `Prefer: return=representation`, so the new record IDs are + captured and bound in the same run; - more than one linked team exists → stop and require administrator remediation. Do not rely on the script's exit code because this source version can print `[FAIL]` for multiple teams without returning failure. @@ -176,15 +218,14 @@ 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`. -When the authorization and team already exist but workflow shares are missing, -the unchanged script's `-WhatIf` run may exit `1` after showing the correct -`would share` plan. This happens because its final verification checks for -shares that `-WhatIf` intentionally did not write. Treat that preview as -acceptable only when the target values are correct and every `[FAIL]` is solely -an expected missing share corresponding to a listed preview operation. A -`would create` authorization/team operation, authentication, permission, -lookup, missing-flow, wrong-target, or unexpected-existing-record error remains -blocking. Do not apply based on an ambiguous preview. +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 +records and shares that `-WhatIf` intentionally did not write. Treat that +preview as acceptable only when the target values are correct and every +`[FAIL]` corresponds exactly to a listed preview operation. Authentication, +permission, lookup, missing-flow, wrong-target, conflicting-existing-record, or +multiple-team errors remain blocking. Do not apply based on an ambiguous +preview. DA4.6 passes only when the preflight found exactly one linked Access team, the script exits with code `0`, ends with 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 44616e61e..2009dc088 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 @@ -66,6 +66,29 @@ and DA2.1, skip any row whose `setupStatus` state is already `done`. ## DA2.0 — Role gate (App / Cloud Application Administrator) +Before querying roles or changing any application, align Azure CLI to the +canonical tenant selected during `/setup`: + +1. Read `environment.tenant_id` from `.local/setup/config.json` and save it as + `SETUP_TENANT_ID`. If it is absent, stop and ask the user to rerun `/setup`; + never infer the tenant from the current Azure CLI session. +2. Read the active Azure CLI tenant: + + ``` + az account show --query tenantId -o tsv + ``` + +3. If it does not exactly equal `SETUP_TENANT_ID`, sign in to the setup tenant: + + ``` + az login --tenant "{SETUP_TENANT_ID}" --use-device-code --allow-no-subscriptions + ``` + +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 + `.local/connect/workday-da/config.json` only after this verification. + Apply the shared [`shared/permission-gate.md`](shared/permission-gate.md) before any Entra work, with: @@ -175,9 +198,19 @@ carries `http://www.workday.com/{tenant}` in its `identifierUris`, so no guessin is needed: ``` -az ad app list --all --query "[?identifierUris[?contains(@, 'workday.com/{tenant}')]].{name:displayName, appId:appId, id:id, identifierUris:identifierUris}" -o json +$targetIdentifier = "http://www.workday.com/{tenant}" +$normalizedTarget = $targetIdentifier.Trim().TrimEnd('/').ToLowerInvariant() +$apps = az ad app list --all --query "[?identifierUris != null].{name:displayName, appId:appId, id:id, identifierUris:identifierUris}" -o json | ConvertFrom-Json +$matches = @($apps | Where-Object { + @($_.identifierUris | ForEach-Object { + ([string]$_).Trim().TrimEnd('/').ToLowerInvariant() + }) -contains $normalizedTarget +}) ``` +- Match only normalized **exact equality** as shown above. Never use + `contains()` or substring matching: a tenant such as `microsoft_dpt6` can + coexist with `microsoft_dpt6_okta`, and substring matching selects both. - **Exactly one match** → this is unambiguously the right app. Save its `appId` → `WD_ENTRA_APP_ID` and its `id` → `WD_ENTRA_APP_OBJECT_ID`, then resolve the service-principal id (`az ad sp list --filter "appId eq '{WD_ENTRA_APP_ID}'" diff --git a/tests/flightcheck/checks/test_workday_package.py b/tests/flightcheck/checks/test_workday_package.py index 35eb175a2..4a8266bba 100644 --- a/tests/flightcheck/checks/test_workday_package.py +++ b/tests/flightcheck/checks/test_workday_package.py @@ -24,6 +24,7 @@ import pytest import responses +from unittest.mock import patch from tests.conftest import require_validated_mock from tests.mocks import dataverse as dv @@ -356,6 +357,32 @@ def test_runtime_all_bound_passes(self, runner: _MinimalRunner) -> None: assert r.status == "Passed" assert "Workday Runtime (OBO)" in r.result + def test_runtime_detection_ignores_agent_scoped_refs( + self, runner: _MinimalRunner + ) -> None: + from flightcheck.checks.workday import _check_package_flavor + + refs = dv.workday_connection_refs_runtime() + refs.append({ + "connectionreferencelogicalname": ( + "gptagent_copilotforemployeeselfservicehr." + "3164dae9-3a2b-5843-98dd-e62bb5123324.shared_workdaysoap" + ), + "connectionreferencedisplayname": "Agent Workday Runtime", + "connectorid": "/providers/Microsoft.PowerApps/apis/shared_workdaysoap", + "connectionid": "agent-connection", + "statuscode": 1, + }) + runner._workday_connection_refs = refs + + with patch("auth.query_all", return_value=refs): + results = _check_package_flavor(runner, wd_flows=[{"name": "runtime"}]) + + r = _result_by_id(results, "WD-PKG-001") + assert r.status == "Passed" + assert runner._workday_package_flavor == "simplified" + assert len(runner._workday_connection_refs) == 1 + def test_runtime_unbound_fails(self, runner: _MinimalRunner) -> None: from flightcheck.checks.workday import _check_package_connection_completeness diff --git a/tests/flightcheck/checks/test_workday_saml_certificate.py b/tests/flightcheck/checks/test_workday_saml_certificate.py index 45fbd9a18..3f64c56dc 100644 --- a/tests/flightcheck/checks/test_workday_saml_certificate.py +++ b/tests/flightcheck/checks/test_workday_saml_certificate.py @@ -624,6 +624,38 @@ def test_malformed_custom_key_identifier_does_not_crash( assert "(malformed)" in r.result +class TestPreferredThumbprintDisplay: + """The preferred SHA-1 thumbprint is authoritative for a single cert.""" + + @responses.activate + def test_single_cert_uses_preferred_sha1_when_custom_identifier_is_sha256( + self, runner: _MinimalRunner + ) -> None: + from flightcheck.checks.workday import _check_saml_certificate_health + + sha256_identifier = base64.b64encode(b"\xAB" * 32).decode("ascii") + preferred = "905236D91F87B87FDD6AD3A832909751D7C403EA" + kc = g.key_credential( + key_id="cert-sha256-identifier", + custom_key_identifier=sha256_identifier, + end_date_time="2099-01-01T00:00:00Z", + ) + sp = g.service_principal( + sp_id="sp-workday-preferred", + display_name="Workday Preferred", + key_credentials=[kc], + preferred_token_signing_key_thumbprint=preferred, + ) + responses.add(**g.list_service_principals(service_principals=[sp])) + + results = _check_saml_certificate_health(runner) + r = _result_by_id(results, "WD-CONN-102") + + assert r.status == "Manual" + assert "90:52:36:D9:1F:87:B8:7F:DD:6A:D3:A8:32:90:97:51:D7:C4:03:EA" in r.result + assert "(malformed)" not in r.result + + # ─────────────────────────────────────────────────────────────────────── diff --git a/tests/flightcheck/test_check_roles.py b/tests/flightcheck/test_check_roles.py index 7688efb02..d812e5f89 100644 --- a/tests/flightcheck/test_check_roles.py +++ b/tests/flightcheck/test_check_roles.py @@ -150,6 +150,22 @@ def test_terminal_summary_shows_roles_on_action_rows(capsys): assert "X-004" in out +def test_terminal_summary_does_not_count_skipped_as_passed(capsys): + from flightcheck.runner import RunResult + from flightcheck.cli import _print_prioritized_summary + + rr = RunResult(scope="full", started="2026-01-01T00-00-00", overall="READY") + rr.results = [_result("X-SKIP", "Skipped", [])] + rr.total = 1 + rr.skipped = 1 + + _print_prioritized_summary(rr) + + out = capsys.readouterr().out + assert "SKIPPED (1)" in out + assert "PASSED (0)" in out + + def test_publishing_checks_all_carry_roles(): """Every result from a real check module must declare at least one role.""" from flightcheck.checks.publishing import run_publishing_checks diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index e4d949869..e564b7185 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -105,6 +105,10 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: ).read_text(encoding="utf-8") assert "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" in entra assert "label-to-app mapping" in entra + assert 'az account show --query tenantId -o tsv' in entra + assert '--tenant "{SETUP_TENANT_ID}"' in entra + assert "normalized **exact equality**" in entra + assert "contains(@, 'workday.com/{tenant}')" not in entra power_platform = ( _SOLUTION @@ -116,6 +120,18 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: ).read_text(encoding="utf-8") assert "exactly one MCSBot delegated authorization" in power_platform assert "contains no `[FAIL]` line" in power_platform + assert "Azure CLI Dataverse\ntoken before use" in power_platform + assert "msdyn_sharedworkdaysoap_workdayruntime" in power_platform + assert "msdyn_sharedcommondataserviceforapps_workdayruntime" in power_platform + + authorization_script = ( + _SOLUTION + / "scripts" + / "alm" + / "Enable-CosmosDAFlowAuthorization.ps1" + ).read_text(encoding="utf-8") + assert "Test-DataverseToken" in authorization_script + assert "get_dataverse_token.py" in authorization_script install = ( _SOLUTION From 8f884e1ada1a62a4d9b1d0f836009bf01ee7646a Mon Sep 17 00:00:00 2001 From: is-goutham Date: Tue, 22 Sep 2026 00:58:03 -0700 Subject: [PATCH 17/17] fix(connect): simplify Workday maker setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/skills/setup/workday-da/SKILL.md | 19 +- .../workday-da/configure-power-platform.md | 211 +++++++++--------- .../setup/workday-da/shared/config-schema.md | 11 +- .../src/skills/setup/workday-da/tasks.md | 32 +-- .../setup/workday-da/verify-connection.md | 2 +- tests/setup/test_setup_router.py | 28 ++- 6 files changed, 160 insertions(+), 143 deletions(-) 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 c5f70a839..0294dabc8 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 @@ -223,13 +223,13 @@ When it returns, go back to **Start** to resume at the next unverified row. ### DA4.1 through DA4.8 — Configure Power Platform and agent integration (DA-4) Read `src/skills/setup/workday-da/configure-power-platform.md` and follow it. -That playbook configures or guides the Workday OAuthUser and Dataverse -connections, parameter sharing, stale-connection recovery, connection binding, -cloud-flow state, checked-in script authorization, DA V2 employee context, topic -selection, and firewall allowlisting. It updates rows **DA4.1**–**DA4.8** -through the shared checklist-updater. Manual and attestation rows require -explicit evidence; DA4.6 is programmatic and passes only when the checked-in -authorization script verifies every target workflow. +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. When it returns, go back to **Start** to resume at DA5.1. @@ -247,8 +247,7 @@ When it returns, go back to **Start** — every row should now be `done`. **Message:** Your ESS DA HR Agent is connected to Workday and the signed-in employee path -has been validated in this environment. Run `/setup` once to refresh the -environment's runtime-readiness checks. This keeps the completed Workday -connection and shows any unrelated prerequisite that still needs attention. +has been validated in this environment. The Workday connection is ready; you +do not need to run `/setup` again. **End message.** 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 305441e5a..f22d39fed 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 @@ -9,57 +9,46 @@ 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. -The two required connection references depend on the package flavor installed -in DA-1: +The two required runtime connection references are: -| Package | Connection | Logical name | -| --- | --- | --- | -| MOS runtime (`msdyn_EssWorkdayRuntime`) | Workday OAuthUser | `msdyn_sharedworkdaysoap_workdayruntime` | -| MOS runtime (`msdyn_EssWorkdayRuntime`) | Microsoft Dataverse | `msdyn_sharedcommondataserviceforapps_workdayruntime` | -| Legacy DA (`msdyn_EssDAHRWorkdayHCM`) | Workday OAuthUser | `new_sharedworkdaysoap_ff0df` | -| Legacy DA (`msdyn_EssDAHRWorkdayHCM`) | Microsoft Dataverse | `msviess_sharedcommondataserviceforapps_92b66` | - -Resolve the rows from the installed solution and connector IDs before showing -portal instructions. Do not report a missing connection merely because a -logical name from the other package flavor is absent. +| Connection | Logical name | +| --- | --- | +| Workday OAuthUser | `msdyn_sharedworkdaysoap_workdayruntime` | +| Microsoft Dataverse | `msdyn_sharedcommondataserviceforapps_workdayruntime` | Never reuse Dev connection IDs, bot IDs, or workflow IDs in another environment. --- -## DA4.0 — Determine fresh setup or simplified-setup upgrade +## DA4.0 — Prepare the connections page + +This setup always uses the signed-in employee Workday runtime. Do not present +an installation-path or migration choice. -Ask whether this environment is a fresh simplified setup or an upgrade from a -legacy ISU/RaaS Workday installation. +Build the environment's Connections URL from the recorded ring: -- **Fresh setup** — continue normally. -- **Upgrade** — explain that updating the Workday package can make its - connection stale. Reuse and update the existing Workday API client where - appropriate, add the required functional areas and Workday-owned scope, - reconnect the Power Platform connection, and move to the package's V2 - signed-in-user context. Do not decommission the legacy ISU, security groups, - RaaS reports, or credentials until the simplified path has passed in every - environment and has been observed for an agreed business cycle. +- `preprod` → + `https://make.preprod.powerautomate.com/environments/{ENV_ID}/connections` +- `prod` → + `https://make.powerautomate.com/environments/{ENV_ID}/connections` -Persist `installPath: "simplified"` and, for an upgrade, also persist -`migrationSource: "legacy-isu-raas"`. +Persist `installPath: "simplified"`. ## DA4.1 — Connect the Workday OAuthUser reference **Message:** -Now we'll create the Workday connection used on behalf of each signed-in -employee, then attach it to the installed Workday solution. +Now we'll create the two connections the Workday runtime needs. + +Open this environment's **Connections** page: -1. In the Power Apps maker portal, select this environment. -2. Open **Connections** → **New connection**, search for **Workday**, and create - a connection using **Microsoft Entra ID Integrated** authentication. +{CONNECTIONS_URL} + +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. -4. After the connection shows **Connected**, open **Solutions** → the installed - Workday runtime solution → **Connection references**. -5. Open **Workday OAuth Runtime**, select the connection you just created, and - save. Reopen it once to confirm the selection was retained. +4. Wait until the Workday connection shows **Connected**. Use the values captured earlier: @@ -70,15 +59,13 @@ Use the values captured earlier: - SOAP base URL: `{soapBaseUrl}`. - REST base URL: `{restBaseUrl}`. It must end exactly at `/api`. -Even if the page shows every reference as connected after the first connection, -open and configure each reference separately. - **End message.** If no Workday connection exists yet, do not ask the maker to confirm the -reference binding—guide the connection creation first. Ask the maker to confirm -that OAuthUser is connected with the values above and that the -`connectionreference` row has a non-empty `connectionid`. +reference binding—guide the connection creation first. Re-read the ring-native +connection inventory and confirm the new `shared_workdaysoap` connection is +`Connected` and carries the expected resource, token, client, SOAP, REST, and +tenant values. Record DA4.1 with `GATE="manual"`, `ACK=true`, and evidence describing the connection name and environment. If it is not connected, leave the row `in-progress`. @@ -87,72 +74,39 @@ connection name and environment. If it is not connected, leave the row **Message:** -1. In Power Apps **Connections**, create a **Microsoft Dataverse** connection - with your maker account if this environment does not already have one. -2. Return to the installed Workday runtime solution → **Connection references**. -3. Open **Microsoft Dataverse - Workday Runtime**, select that Dataverse - connection, and save. -4. Reopen the reference and confirm the selection was retained and targets this - environment, not a Dev connection copied from another environment. - -**End message.** - -Ask for confirmation and record DA4.2 as a manual row with the connection name -and environment in the evidence. Verify its `connectionreference` row has a -non-empty `connectionid`; if it remains empty, do not advance to flow activation. - -## DA4.3 — Share parameters and repair stale connections - -**Message:** - -Open the active agent's **Copilot Studio → Settings → Connection settings** -page: - -`https://{CPS_HOST}/environments/{ENV_ID}/copilots/{BOT_ID}/da-settings/connectionSettings` - -For each Workday flow entry: - -1. Click the entry → **Connect**. -2. Select the Workday connection created in DA4.1 and submit. -3. Under **Manage**, click **See details**. -4. Open the **Connection parameters** tab. -5. Turn on **Allow permission to share parameters** and save. - -If the parameter values appear empty, turn the setting off and save, turn it -back on and save again, then confirm the REST, SOAP, token, client, and resource -values are still populated. - -If the connection is **Stale**, **Needs attention**, or no longer connected -after a package update or parameter change, reconnect it now. Users may also be -asked to authorize again on their next Workday request. +On the same **Connections** page, select **New connection** and create a +**Microsoft Dataverse** connection with your maker account. Wait until both the +Workday and Dataverse connections show **Connected**, then tell me they are +ready. **End message.** -This setting is agent/runtime wiring; 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. Record DA4.3 as -manual. +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. -## DA4.4 — Confirm connection binding +## DA4.3 — Bind the extension connections -Guide the maker through the Workday solution's connection-reference and -connection-parameter configuration. Confirm: +Bind the installed solution references programmatically: -1. OAuthUser is bound to the Workday connection created in DA4.1. -2. Microsoft Dataverse is bound to the Dataverse connection from DA4.2. -3. The required `connectionparametersetconfig` values are populated for the - target environment. -4. No reference points to a connection from a different environment or user. -5. The Copilot Studio connection-settings entries are wired separately per - DA4.3; solution-level Dataverse binding does not replace that runtime step. +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. -The authorization script does not perform this step. Require explicit -confirmation and record DA4.4 as manual. +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. -## DA4.5 — Turn on the Workday cloud flows +## DA4.4 — Turn on the Workday cloud flows -Do not activate flows until DA4.1–DA4.4 are complete and the two installed +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. @@ -171,8 +125,37 @@ flows from other solutions. **End message.** -Record DA4.5 only after the maker confirms the complete DA Workday flow set is -on. +Record DA4.4 only after every target Workday flow is verified as active. + +## DA4.5 — Connect the agent and share parameters + +**Message:** + +The Workday and Dataverse connections are ready and the runtime flows are on. +Now connect those flows to this agent: + +1. Open the active agent's **Copilot Studio → Settings → Connection settings** + page: + + `https://{CPS_HOST}/environments/{ENV_ID}/copilots/{BOT_ID}/da-settings/connectionSettings` +2. Open each Workday flow entry and select **Connect**. +3. Select the Workday connection created earlier and submit. +4. Under **Manage**, select **See details**. +5. Open **Connection parameters**. +6. Turn on **Allow permission to share parameters** and save. + +If the parameter values appear empty, turn the setting off and save, turn it +back on and save again, then confirm the REST, SOAP, token, client, and resource +values remain populated. + +**End message.** + +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. ## DA4.6 — Authorize the DA to use the Workday flows @@ -245,24 +228,36 @@ of reading `workspace/flightcheck/results.json`. On an apply or verification failure, use `CHECKPOINT_RESULT="FAILED"`, `RESULT_SOURCE="external"`, and the safe failure summary so the row becomes blocked. -## DA4.7 — Configure employee context and selected topics +## DA4.7 — Configure employee context and Workday topics 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. -Present the available Workday topics and let the maker choose which scenarios -to enable. Preview the exact topic changes and obtain approval before mutating -the agent. Confirm: +Present these choices: + +1. **Enable all Workday topics** — recommended for makers who want the complete + Workday experience. +2. **Choose specific Workday topics** — show a multi-select list of available + business scenarios. +3. **Keep the current topic selection** — make no topic-status changes. + +Whichever option is selected, include the V2 signed-in-user context and every +system dependency required by the selected business topics. Preview the exact +topic list and obtain approval before changing anything. Confirm: - the DA-equivalent V2 user-context component is enabled and wired; - selected topics are enabled; - unselected topics remain disabled; -- no legacy ISU/RaaS user-context component is selected for the simplified - path. - -If the package does not expose enough metadata to make this safe, provide the -equivalent Copilot Studio steps and record DA4.7 as manual after confirmation. +- choosing **Enable all** enables every installed Workday business topic plus + the required Workday system topics. + +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. ## DA4.8 — Record firewall allowlisting 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 8d22b60ae..48ddae732 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 @@ -51,7 +51,6 @@ read by later steps. Unknown/absent fields are treated as `null`. | `domainName` | string | DA-3 | Workday domain name, when discovered. | | `tenantId` | string | DA-2 | **Entra** tenant ID (GUID) — set during Entra setup. | | `installPath` | string | DA-3/DA-4 | `"simplified"`. | -| `migrationSource` | string | DA-4 | `"legacy-isu-raas"` when upgrading an existing legacy setup; absent for a fresh simplified setup. | | `status` | string | all | `"in-progress"` \| `"configured"` \| `"ready"`. `"configured"` means setup values are recorded but runtime is not proven. Only DA-5 sets `"ready"` after a signed-in Workday scenario succeeds. | | `verticals` | array[string] | DA-1 | Always `["hr"]` for this release. ESS DA IT is not supported by `/connect workday`. | | `vertical` | string | DA-1 | Always `"hr"` for this release. | @@ -99,11 +98,11 @@ same data. ## Power Platform integration state -DA-4 records manual evidence for connection references, parameter sharing, -binding, flow state, selected topics, and firewall allowlisting until reliable -DA-scoped APIs are available. It must not reuse CEA checkpoints as proof. -DA4.6 is the exception: its programmatic evidence comes from the checked-in -authorization script. +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 +evidence from the checked-in authorization script. --- 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 c36e2d968..770751354 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 @@ -53,21 +53,21 @@ express; all items start `pending`. - [ ] **Install the Workday extension package** — Add the Workday extension package to your ESS DA HR agent so it can talk to Workday. If the HR base agent isn't installed yet, this step sends you to `/setup` first. -### 2. Workday single sign-on (Entra) +### 2. Connect Microsoft Entra sign-in to Workday -- [ ] **Create the Workday single sign-on app** — Set up the Entra SSO application for Workday in SAML mode, with the right sign-on URLs and an active signing certificate. +- [ ] **Set up Workday sign-in** — Create the Microsoft Entra application Workday uses to recognize signed-in employees. -- [ ] **Expose the Workday API permission** — Publish the sign-in scope, pre-authorize the Workday connector, and request the Microsoft Graph permissions the agent needs. +- [ ] **Allow Power Platform to call Workday** — Add the permission used by the Workday connector and the Microsoft Graph permissions needed for sign-in. -- [ ] **Grant admin consent** — Approve the requested Microsoft Graph permissions on behalf of your organization. +- [ ] **Approve the sign-in permissions** — Grant organization-wide consent for the permissions the Workday connection needs. -- [ ] **Assign users to the Workday app** — Give the right people or groups access to the Workday enterprise application, or confirm assignment isn't required. +- [ ] **Choose who can use Workday** — Assign the employees or groups allowed to use the Workday application, or confirm assignment is not required. -- [ ] **Map the sign-in identifier** — Configure the NameID claim so Workday recognizes each signed-in employee. +- [ ] **Match the signed-in employee** — Configure the sign-in identifier Workday uses to find the current employee. -- [ ] **Set the SAML signing option** — Turn on "Sign SAML response and assertion" so Workday trusts the sign-in tokens. +- [ ] **Sign the Workday sign-in response** — Turn on "Sign SAML response and assertion" so Workday trusts the sign-in response. -- [ ] **Confirm a single sign-in tenant** — Verify Workday and Entra are federated to the same single tenant so sign-in lines up. +- [ ] **Confirm the correct Microsoft Entra tenant** — Verify Workday is connected to this environment's Microsoft Entra tenant. ### 3. Workday tenant configuration @@ -83,16 +83,16 @@ express; all items start `pending`. ### 4. Power Platform and agent integration -- [ ] **Connect the Workday account** — Create or reconnect the Workday OAuthUser connection with Microsoft Entra ID Integrated sign-in and the captured Workday endpoints. +- [ ] **Create the Workday connection** — Create the signed-in employee Workday connection with the captured Workday endpoints. -- [ ] **Connect Microsoft Dataverse** — Bind the Workday extension's Dataverse connection reference to an active connection owned by the maker. +- [ ] **Create the Microsoft Dataverse connection** — Create or select an active Dataverse connection owned by the maker in this environment. -- [ ] **Share the Workday connection parameters** — Allow the extension to share its connection parameters for on-behalf-of authentication, and repair any stale connection after package or parameter changes. - -- [ ] **Bind the extension connections** — Confirm the Workday and Dataverse references and required connection parameter configuration point to this environment's connections. - -- [ ] **Turn on the Workday cloud flows** — Confirm every Workday flow used by the ESS DA HR Agent is enabled. - +- [ ] **Bind the extension connections** — Attach the Workday and Dataverse connections to the installed Workday runtime references. + +- [ ] **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 DA HR Agent. - [ ] **Configure employee context and topics** — Use the DA package's V2 signed-in-user context and enable the Workday topics selected for this agent. 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 869a39d38..0fab24751 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 @@ -65,7 +65,7 @@ path: 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 an unexpected generic or ISU sign-in. + Workday data without asking for another unexpected sign-in. Did the scenario complete successfully? diff --git a/tests/setup/test_setup_router.py b/tests/setup/test_setup_router.py index e564b7185..027afd62e 100644 --- a/tests/setup/test_setup_router.py +++ b/tests/setup/test_setup_router.py @@ -79,8 +79,7 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: assert "stable slug,\n`botId`" in da_skill assert 'steps.SETUP-07.state: "done"' in da_skill assert "Do not\nrequire `connect_ready: true`" in da_skill - assert "Run `/setup` once to refresh the" in da_skill - assert "shows any unrelated prerequisite" in da_skill + assert "you\ndo not need to run `/setup` again" in da_skill assert 'provider `status` to be `"ready"`' in da_skill updater = ( @@ -123,6 +122,31 @@ def test_connect_workday_provider_contract_and_review_guards() -> None: assert "Azure CLI Dataverse\ntoken before use" in power_platform assert "msdyn_sharedworkdaysoap_workdayruntime" in power_platform assert "msdyn_sharedcommondataserviceforapps_workdayruntime" in power_platform + assert "Enable all Workday topics" in power_platform + assert "legacy ISU/RaaS" not in power_platform + assert "migrationSource" not in power_platform + assert "make.preprod.powerautomate.com" in power_platform + + verify_connection = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "verify-connection.md" + ).read_text(encoding="utf-8") + assert "generic or ISU sign-in" not in verify_connection + + tasks = ( + _SOLUTION + / "src" + / "skills" + / "setup" + / "workday-da" + / "tasks.md" + ).read_text(encoding="utf-8") + assert "Connect Microsoft Entra sign-in to Workday" in tasks + assert "Match the signed-in employee" in tasks authorization_script = ( _SOLUTION