diff --git a/.github/extensions/process-workflow-fleet/README.md b/.github/extensions/process-workflow-fleet/README.md
new file mode 100644
index 00000000..27ac75e8
--- /dev/null
+++ b/.github/extensions/process-workflow-fleet/README.md
@@ -0,0 +1,103 @@
+# Process workflow fleet canvas
+
+This project-scoped Copilot canvas lets maintainers refresh the authenticated
+Process-PSModule caller inventory, inspect each repository against the v8 caller
+contract, and send a confirmed migration request to the active agent. The
+loopback server never edits another repository or opens a pull request.
+
+## Structure
+
+| File | Responsibility |
+| --- | --- |
+| `extension.mjs` | Declares the canvas, strict open/action schemas, lifecycle, and SDK wiring. |
+| `fleet-service.mjs` | Resolves the repository, stores workspace-scoped state, runs inventory refreshes, serves loopback HTTP, and calls `session.send()`. |
+| `fleet-model.mjs` | Normalizes inventory records, encodes the v8 target, computes deltas, and builds structured migration prompts. |
+| `renderer.mjs` | Returns the dependency-free dashboard HTML, CSS, and browser interactions. |
+| `fleet-model.test.mjs` | Tests normalization, comparison, fail-closed behavior, and prompt generation with built-in Node modules. |
+
+`extension.mjs` must remain an ES module with that exact name. Copilot resolves
+`@github/copilot-sdk` for the extension process, so this directory does not need
+a `package.json` or `node_modules`.
+
+## Load and open
+
+Copilot discovers immediate children of `.github/extensions/`. After changing
+the extension, reload project extensions and confirm
+`project:process-workflow-fleet` is running:
+
+```text
+extensions_reload({})
+extensions_manage({ operation: "list" })
+extensions_manage({ operation: "inspect", name: "process-workflow-fleet" })
+```
+
+Inspect the declaration, then open a stable panel instance:
+
+```text
+list_canvas_capabilities({ canvasId: "process-workflow-fleet" })
+open_canvas({
+ canvasId: "process-workflow-fleet",
+ instanceId: "process-workflow-fleet-main",
+ input: { organization: "PSModule" }
+})
+```
+
+Reopening the same `instanceId` focuses the panel. Durable inventory and
+selection state is keyed by the repository workspace under the Copilot session
+`files/process-workflow-fleet/` artifact directory, not by the panel ID.
+Refreshed evidence is disposable user-specific state and is never committed
+automatically.
+
+## Use and test
+
+The dashboard automatically refreshes missing inventory and evidence older than
+15 minutes. Its refresh button provides an explicit retry. Both paths run
+`.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1` in authenticated
+GitHub mode with target `v8`. A failed refresh clears prior success state and
+shows the command context and sanitized diagnostic.
+
+Agent-facing actions are:
+
+- `refresh_inventory`
+- `get_summary`
+- `get_repository`
+- `set_selection`
+- `request_migration`
+
+`request_migration` returns a preview by default. It calls `session.send()` only
+when `dryRun` is `false`, `confirmed` is `true`, the selection is nonempty, and
+the current inventory is complete.
+
+Run deterministic helper tests with:
+
+```powershell
+node --test .github/extensions/process-workflow-fleet/fleet-model.test.mjs
+```
+
+## Debug
+
+Start with `extensions_manage({ operation: "inspect", name:
+"process-workflow-fleet" })`. The reported log captures provider startup and
+runtime failures; do not add `console.log`, because standard output carries the
+JSON-RPC protocol. Use `session.log()` for deliberate diagnostics.
+
+For lifecycle and schema checks, reload before testing and verify:
+
+1. discovery and capabilities;
+2. valid and invalid open input;
+3. each declared action and invalid action input;
+4. reserved `canvas.*` action rejection;
+5. loopback rendering and panel cleanup.
+
+## Extend safely
+
+Keep the SDK declaration in `extension.mjs`, domain logic in
+`fleet-model.mjs`, privileged boundaries in `fleet-service.mjs`, and rendering
+in `renderer.mjs`. Add a strict JSON schema for every new agent action and a
+deterministic test for every comparison or prompt change.
+
+Bind HTTP only to `127.0.0.1`, require the per-panel request token for writes,
+and close the server in `onClose`. HTTP handlers may prepare evidence or
+requests, but repository mutations must stay in normal Copilot sessions where
+the user can see tool calls and permission prompts. Never include credentials
+in state, HTML, diagnostics, or migration requests.
diff --git a/.github/extensions/process-workflow-fleet/extension.mjs b/.github/extensions/process-workflow-fleet/extension.mjs
new file mode 100644
index 00000000..42ad8665
--- /dev/null
+++ b/.github/extensions/process-workflow-fleet/extension.mjs
@@ -0,0 +1,178 @@
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ CanvasError,
+ createCanvas,
+ joinSession,
+} from "@github/copilot-sdk/extension";
+
+import {
+ createFleetService,
+ resolveRepositoryRoot,
+} from "./fleet-service.mjs";
+
+const moduleDirectory = dirname(fileURLToPath(import.meta.url));
+const repositoryRoot = resolveRepositoryRoot({
+ currentWorkingDirectory: process.cwd(),
+ moduleDirectory,
+});
+
+let session;
+const fleet = createFleetService({
+ getSession: () => session,
+ repositoryRoot,
+});
+
+const canvas = createCanvas({
+ id: "process-workflow-fleet",
+ displayName: "Process workflow fleet",
+ description:
+ "Inspect Process-PSModule caller workflows, compare them with the v8 contract, and request repository-scoped migrations.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ organization: {
+ type: "string",
+ minLength: 1,
+ maxLength: 100,
+ pattern: "^[A-Za-z0-9][A-Za-z0-9-]*$",
+ },
+ },
+ },
+ actions: [
+ {
+ name: "refresh_inventory",
+ description:
+ "Refresh the authenticated GitHub inventory and replace prior canvas data fail-closed.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ organization: {
+ type: "string",
+ minLength: 1,
+ maxLength: 100,
+ pattern: "^[A-Za-z0-9][A-Za-z0-9-]*$",
+ },
+ repositories: {
+ type: "array",
+ uniqueItems: true,
+ maxItems: 500,
+ items: {
+ type: "string",
+ minLength: 1,
+ maxLength: 200,
+ pattern:
+ "^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?$",
+ },
+ },
+ includeArchived: {
+ type: "boolean",
+ },
+ },
+ },
+ handler: async (ctx) => fleet.refreshInventory(ctx.input ?? {}),
+ },
+ {
+ name: "get_summary",
+ description:
+ "Return refresh health and v8 compliance counts for the current workspace inventory.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {},
+ },
+ handler: async () => fleet.getSummary(),
+ },
+ {
+ name: "get_repository",
+ description:
+ "Return normalized workflow evidence and the migration delta for one repository.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ required: ["repository"],
+ properties: {
+ repository: {
+ type: "string",
+ minLength: 1,
+ maxLength: 200,
+ },
+ },
+ },
+ handler: async (ctx) => fleet.getRepository(ctx.input.repository),
+ },
+ {
+ name: "set_selection",
+ description:
+ "Persist the selected repository identities for this session workspace.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ required: ["repositories"],
+ properties: {
+ repositories: {
+ type: "array",
+ uniqueItems: true,
+ maxItems: 500,
+ items: {
+ type: "string",
+ minLength: 1,
+ maxLength: 200,
+ },
+ },
+ },
+ },
+ handler: async (ctx) => fleet.setSelection(ctx.input.repositories),
+ },
+ {
+ name: "request_migration",
+ description:
+ "Preview or confirm an agent-orchestrated migration request for selected repositories; never mutates repositories directly.",
+ inputSchema: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ repositories: {
+ type: "array",
+ uniqueItems: true,
+ maxItems: 500,
+ items: {
+ type: "string",
+ minLength: 1,
+ maxLength: 200,
+ },
+ },
+ dryRun: {
+ type: "boolean",
+ },
+ confirmed: {
+ type: "boolean",
+ },
+ },
+ },
+ handler: async (ctx) => fleet.requestMigration(ctx.input ?? {}),
+ },
+ ],
+ open: async (ctx) => {
+ const entry = await fleet.openPanel(ctx.instanceId, {
+ organization: ctx.input?.organization,
+ });
+ return {
+ title: "Process workflow fleet",
+ status: "Inventory updates automatically",
+ url: entry.url,
+ };
+ },
+ onClose: async (ctx) => {
+ await fleet.closePanel(ctx.instanceId);
+ },
+});
+
+session = await joinSession({
+ canvases: [canvas],
+});
+
+fleet.setCanvasErrorFactory((code, message) => new CanvasError(code, message));
diff --git a/.github/extensions/process-workflow-fleet/fleet-model.mjs b/.github/extensions/process-workflow-fleet/fleet-model.mjs
new file mode 100644
index 00000000..7df4c139
--- /dev/null
+++ b/.github/extensions/process-workflow-fleet/fleet-model.mjs
@@ -0,0 +1,644 @@
+const EXPECTED_WORKFLOW_PATH = ".github/workflows/Process-PSModule.yml";
+const EXPECTED_WORKFLOW_NAME = "Process-PSModule";
+const EXPECTED_JOB_NAME = "Process-PSModule";
+const EXPECTED_USES =
+ "PSModule/Process-PSModule/.github/workflows/workflow.yml@v8";
+const EXPECTED_CONCURRENCY_GROUP =
+ "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}";
+const EXPECTED_CANCEL_IN_PROGRESS =
+ "${{ github.event_name == 'pull_request' }}";
+
+const REQUIRED_EVENTS = [
+ "pull_request",
+ "push",
+ "schedule",
+ "workflow_dispatch",
+];
+const REQUIRED_PULL_REQUEST_TYPES = [
+ "closed",
+ "labeled",
+ "opened",
+ "reopened",
+ "synchronize",
+ "unlabeled",
+];
+const REQUIRED_JOB_PERMISSIONS = {
+ contents: "read",
+ pages: "write",
+ "id-token": "write",
+};
+const REQUIRED_SECRETS = {
+ PSGALLERY_API_KEY: "${{ secrets.PSGALLERY_API_KEY }}",
+ GitHubAppClientId: "${{ secrets.SHELLY_CLIENT_ID }}",
+ GitHubAppPrivateKey: "${{ secrets.SHELLY_PRIVATE_KEY }}",
+};
+const OPTIONAL_INPUTS = new Set([
+ "ImportantFilePatterns",
+ "Prerelease",
+ "SettingsPath",
+ "Verbose",
+ "Version",
+ "WorkingDirectory",
+]);
+const OPTIONAL_SECRETS = new Set(["TestData"]);
+
+const REQUIRED_RECORD_FIELDS = [
+ "WorkflowName",
+ "Events",
+ "Schedules",
+ "PushBranches",
+ "PushBranchesIgnore",
+ "PushPaths",
+ "PushPathsIgnore",
+ "PullRequestBranches",
+ "PullRequestTypes",
+ "PullRequestPaths",
+ "PullRequestPathsIgnore",
+ "ConcurrencyGroup",
+ "CancelInProgress",
+ "Permissions",
+ "ProcessJobs",
+ "AdditionalJobs",
+];
+const REQUIRED_JOB_FIELDS = [
+ "Name",
+ "Uses",
+ "Reference",
+ "Inputs",
+ "SecretMode",
+ "SecretMappings",
+ "Permissions",
+ "Condition",
+];
+
+export const TARGET_CONTRACT = Object.freeze({
+ identity: {
+ workflowPath: EXPECTED_WORKFLOW_PATH,
+ workflowName: EXPECTED_WORKFLOW_NAME,
+ jobName: EXPECTED_JOB_NAME,
+ },
+ triggers: {
+ events: REQUIRED_EVENTS,
+ scheduleRequired: true,
+ pushBranches: ["main"],
+ pullRequestBranches: ["main"],
+ pullRequestTypes: REQUIRED_PULL_REQUEST_TYPES,
+ pathFiltersAllowed: false,
+ },
+ concurrency: {
+ group: EXPECTED_CONCURRENCY_GROUP,
+ cancelInProgress: EXPECTED_CANCEL_IN_PROGRESS,
+ },
+ permissions: {
+ workflow: {},
+ job: REQUIRED_JOB_PERMISSIONS,
+ },
+ caller: {
+ condition: null,
+ uses: EXPECTED_USES,
+ secrets: REQUIRED_SECRETS,
+ optionalSecrets: [...OPTIONAL_SECRETS],
+ optionalInputs: [...OPTIONAL_INPUTS].sort(),
+ debugTrueAllowed: false,
+ },
+ additionalJobs: {
+ allowed: true,
+ boundaryReviewRequired: true,
+ },
+});
+
+function hasOwn(value, property) {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ Object.prototype.hasOwnProperty.call(value, property)
+ );
+}
+
+function toArray(value) {
+ if (value === null || value === undefined) {
+ return [];
+ }
+ return Array.isArray(value) ? value : [value];
+}
+
+function toObject(value) {
+ if (
+ value === null ||
+ value === undefined ||
+ Array.isArray(value) ||
+ typeof value !== "object"
+ ) {
+ return {};
+ }
+ return { ...value };
+}
+
+function normalizedScalar(value) {
+ if (value === null || value === undefined) {
+ return null;
+ }
+ return typeof value === "string" ? value.trim() : value;
+}
+
+function normalizeJob(job, index) {
+ const source = toObject(job);
+ return {
+ sourceIndex: index,
+ sourceFields: Object.keys(source),
+ name: normalizedScalar(source.Name),
+ uses: normalizedScalar(source.Uses),
+ reference: normalizedScalar(source.Reference),
+ inputs: toObject(source.Inputs),
+ secretMode: normalizedScalar(source.SecretMode),
+ secretMappings: toObject(source.SecretMappings),
+ permissions: source.Permissions,
+ environment: source.Environment ?? null,
+ condition: normalizedScalar(source.Condition),
+ };
+}
+
+function normalizeRecord(record, index) {
+ const source = toObject(record);
+ const status = source.Status === "ParseError" ? "parse-error" : "parsed";
+ return {
+ sourceIndex: index,
+ sourceFields: Object.keys(source),
+ repository: normalizedScalar(source.Repository) ?? `unknown-${index + 1}`,
+ defaultBranch: normalizedScalar(source.DefaultBranch),
+ archived: source.Archived === true,
+ repositoryUrl: normalizedScalar(source.RepositoryUrl),
+ workflowPath: normalizedScalar(source.WorkflowPath),
+ workflowUrl: normalizedScalar(source.WorkflowUrl),
+ status,
+ error: normalizedScalar(source.Error),
+ workflowName: normalizedScalar(source.WorkflowName),
+ runName: normalizedScalar(source.RunName),
+ events: toArray(source.Events).map(String),
+ schedules: toArray(source.Schedules).map(String),
+ pushBranches: toArray(source.PushBranches).map(String),
+ pushBranchesIgnore: toArray(source.PushBranchesIgnore).map(String),
+ pushPaths: toArray(source.PushPaths).map(String),
+ pushPathsIgnore: toArray(source.PushPathsIgnore).map(String),
+ pullRequestBranches: toArray(source.PullRequestBranches).map(String),
+ pullRequestTypes: toArray(source.PullRequestTypes).map(String),
+ pullRequestPaths: toArray(source.PullRequestPaths).map(String),
+ pullRequestPathsIgnore: toArray(source.PullRequestPathsIgnore).map(String),
+ concurrencyGroup: normalizedScalar(source.ConcurrencyGroup),
+ cancelInProgress: normalizedScalar(source.CancelInProgress),
+ permissions: source.Permissions,
+ processJobs: toArray(source.ProcessJobs).map(normalizeJob),
+ additionalJobs: toArray(source.AdditionalJobs).map(String),
+ versionComments: toArray(source.VersionComments).map((entry) => ({
+ reference: normalizedScalar(entry?.Reference),
+ version: normalizedScalar(entry?.Version),
+ })),
+ };
+}
+
+export function normalizeInventory(value) {
+ const records = Array.isArray(value) ? value : value ? [value] : [];
+ return records.map(normalizeRecord);
+}
+
+function stableJson(value) {
+ if (Array.isArray(value)) {
+ return JSON.stringify([...value].map(String).sort());
+ }
+ if (value !== null && typeof value === "object") {
+ return JSON.stringify(
+ Object.fromEntries(
+ Object.entries(value)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, entry]) => [key, entry]),
+ ),
+ );
+ }
+ return JSON.stringify(value ?? null);
+}
+
+function equalArray(actual, expected) {
+ return stableJson(actual) === stableJson(expected);
+}
+
+function equalObject(actual, expected) {
+ return stableJson(toObject(actual)) === stableJson(expected);
+}
+
+function missingInventoryFields(record) {
+ const missing = REQUIRED_RECORD_FIELDS.filter(
+ (field) => !record.sourceFields.includes(field),
+ );
+ for (const job of record.processJobs) {
+ for (const field of REQUIRED_JOB_FIELDS) {
+ if (!job.sourceFields.includes(field)) {
+ missing.push(`ProcessJobs[${job.sourceIndex}].${field}`);
+ }
+ }
+ }
+ return missing;
+}
+
+function addDelta(deltas, field, current, target, matches, instruction) {
+ if (!matches) {
+ deltas.push({
+ field,
+ current,
+ target,
+ instruction,
+ });
+ }
+}
+
+function isEmptyCondition(value) {
+ return value === null || value === "";
+}
+
+function hasDebugTrue(inputs) {
+ return Object.entries(inputs).some(
+ ([key, value]) =>
+ key.toLowerCase() === "debug" &&
+ String(value).trim().toLowerCase() === "true",
+ );
+}
+
+function compareSecrets(job, deltas) {
+ addDelta(
+ deltas,
+ "job.secretMode",
+ job.secretMode,
+ "explicit",
+ job.secretMode === "explicit",
+ "Replace inherited or missing secrets with explicit mappings.",
+ );
+
+ for (const [name, target] of Object.entries(REQUIRED_SECRETS)) {
+ addDelta(
+ deltas,
+ `job.secrets.${name}`,
+ job.secretMappings[name] ?? null,
+ target,
+ job.secretMappings[name] === target,
+ `Map ${name} to the agreed repository or organization secret.`,
+ );
+ }
+
+ const unsupported = Object.keys(job.secretMappings).filter(
+ (name) => !hasOwn(REQUIRED_SECRETS, name) && !OPTIONAL_SECRETS.has(name),
+ );
+ addDelta(
+ deltas,
+ "job.secrets.unsupported",
+ unsupported,
+ [],
+ unsupported.length === 0,
+ "Remove unsupported mappings after checking whether they belong in TestData.",
+ );
+}
+
+function compareInputs(job, deltas, preservation) {
+ const inputNames = Object.keys(job.inputs);
+ if (hasOwn(job.inputs, "TestData")) {
+ deltas.push({
+ field: "job.inputs.TestData",
+ current: job.inputs.TestData,
+ target: "secret mapping named TestData",
+ instruction:
+ "Move test data to the optional TestData secret mapping without exposing values.",
+ });
+ }
+
+ addDelta(
+ deltas,
+ "job.inputs.Debug",
+ job.inputs.Debug ?? null,
+ "omitted or false",
+ !hasDebugTrue(job.inputs),
+ "Remove Debug: true so the reusable workflow default remains false.",
+ );
+
+ const unsupported = inputNames.filter(
+ (name) =>
+ name !== "Debug" &&
+ name !== "TestData" &&
+ !OPTIONAL_INPUTS.has(name),
+ );
+ addDelta(
+ deltas,
+ "job.inputs.unsupported",
+ unsupported,
+ [],
+ unsupported.length === 0,
+ "Inspect unsupported inputs against the current reusable-workflow interface.",
+ );
+
+ for (const name of inputNames.filter((entry) => OPTIONAL_INPUTS.has(entry))) {
+ preservation.push({
+ field: `job.inputs.${name}`,
+ value: job.inputs[name],
+ instruction: `Preserve ${name} when it remains valid for this repository.`,
+ });
+ }
+ if (hasOwn(job.secretMappings, "TestData")) {
+ preservation.push({
+ field: "job.secrets.TestData",
+ value: job.secretMappings.TestData,
+ instruction:
+ "Preserve the TestData JSON mapping after verifying referenced secrets and variables.",
+ });
+ }
+}
+
+export function compareRepository(record) {
+ if (record.status === "parse-error") {
+ return {
+ repository: record.repository,
+ workflowPath: record.workflowPath,
+ status: "parse-error",
+ compliant: false,
+ complete: false,
+ requestReady: false,
+ missingFields: [],
+ deltas: [
+ {
+ field: "inventory.parse",
+ current: record.error,
+ target: "a parsed workflow",
+ instruction:
+ "Fix or inspect the YAML parse error before planning migration.",
+ },
+ ],
+ preservation: [],
+ reviewWarnings: [],
+ };
+ }
+
+ const missingFields = missingInventoryFields(record);
+ const deltas = [];
+ const preservation = [];
+ const reviewWarnings = [];
+
+ addDelta(
+ deltas,
+ "identity.workflowPath",
+ record.workflowPath,
+ EXPECTED_WORKFLOW_PATH,
+ record.workflowPath === EXPECTED_WORKFLOW_PATH,
+ "Use the standard caller workflow path.",
+ );
+ addDelta(
+ deltas,
+ "identity.workflowName",
+ record.workflowName,
+ EXPECTED_WORKFLOW_NAME,
+ record.workflowName === EXPECTED_WORKFLOW_NAME,
+ "Use the standard workflow name.",
+ );
+ addDelta(
+ deltas,
+ "triggers.events",
+ record.events,
+ REQUIRED_EVENTS,
+ equalArray(record.events, REQUIRED_EVENTS),
+ "Declare workflow_dispatch, schedule, push, and pull_request.",
+ );
+ addDelta(
+ deltas,
+ "triggers.schedule",
+ record.schedules,
+ "at least one schedule",
+ record.schedules.length > 0,
+ "Keep at least one repository-appropriate scheduled health run.",
+ );
+ addDelta(
+ deltas,
+ "triggers.push.branches",
+ record.pushBranches,
+ ["main"],
+ equalArray(record.pushBranches, ["main"]),
+ "Target the main branch for stable default-branch publication.",
+ );
+ addDelta(
+ deltas,
+ "triggers.push.filters",
+ {
+ branchesIgnore: record.pushBranchesIgnore,
+ paths: record.pushPaths,
+ pathsIgnore: record.pushPathsIgnore,
+ },
+ {},
+ record.pushBranchesIgnore.length === 0 &&
+ record.pushPaths.length === 0 &&
+ record.pushPathsIgnore.length === 0,
+ "Remove push filters that bypass reusable-workflow change evaluation.",
+ );
+ addDelta(
+ deltas,
+ "triggers.pullRequest.branches",
+ record.pullRequestBranches,
+ ["main"],
+ equalArray(record.pullRequestBranches, ["main"]),
+ "Target pull requests into main.",
+ );
+ addDelta(
+ deltas,
+ "triggers.pullRequest.types",
+ record.pullRequestTypes,
+ REQUIRED_PULL_REQUEST_TYPES,
+ equalArray(record.pullRequestTypes, REQUIRED_PULL_REQUEST_TYPES),
+ "Declare closed, opened, reopened, synchronize, labeled, and unlabeled.",
+ );
+ addDelta(
+ deltas,
+ "triggers.pullRequest.filters",
+ {
+ paths: record.pullRequestPaths,
+ pathsIgnore: record.pullRequestPathsIgnore,
+ },
+ {},
+ record.pullRequestPaths.length === 0 &&
+ record.pullRequestPathsIgnore.length === 0,
+ "Remove pull-request path filters that bypass Process-PSModule planning.",
+ );
+ addDelta(
+ deltas,
+ "concurrency.group",
+ record.concurrencyGroup,
+ EXPECTED_CONCURRENCY_GROUP,
+ record.concurrencyGroup === EXPECTED_CONCURRENCY_GROUP,
+ "Key concurrency by workflow and pull-request number or full ref.",
+ );
+ addDelta(
+ deltas,
+ "concurrency.cancelInProgress",
+ record.cancelInProgress,
+ EXPECTED_CANCEL_IN_PROGRESS,
+ record.cancelInProgress === EXPECTED_CANCEL_IN_PROGRESS,
+ "Cancel superseded pull-request runs only.",
+ );
+ addDelta(
+ deltas,
+ "permissions.workflow",
+ record.permissions,
+ {},
+ equalObject(record.permissions, {}),
+ "Set top-level permissions to an empty mapping.",
+ );
+ addDelta(
+ deltas,
+ "jobs.count",
+ record.processJobs.length,
+ 1,
+ record.processJobs.length === 1,
+ "Keep exactly one Process-PSModule delegation job.",
+ );
+
+ for (const job of record.processJobs) {
+ addDelta(
+ deltas,
+ "job.name",
+ job.name,
+ EXPECTED_JOB_NAME,
+ job.name === EXPECTED_JOB_NAME,
+ "Use the standard Process-PSModule job identity.",
+ );
+ addDelta(
+ deltas,
+ "job.permissions",
+ job.permissions,
+ REQUIRED_JOB_PERMISSIONS,
+ equalObject(job.permissions, REQUIRED_JOB_PERMISSIONS),
+ "Grant only contents:read, pages:write, and id-token:write.",
+ );
+ addDelta(
+ deltas,
+ "job.condition",
+ job.condition,
+ null,
+ isEmptyCondition(job.condition),
+ "Remove the caller condition so the reusable workflow owns authorization.",
+ );
+ addDelta(
+ deltas,
+ "job.uses",
+ job.uses,
+ EXPECTED_USES,
+ job.uses === EXPECTED_USES,
+ "Pin the caller to the controlled v8 major reference.",
+ );
+ compareSecrets(job, deltas);
+ compareInputs(job, deltas, preservation);
+ }
+
+ if (record.additionalJobs.length > 0) {
+ reviewWarnings.push({
+ field: "additionalJobs",
+ value: record.additionalJobs,
+ instruction:
+ "Read each repository-owned job before migration and verify it cannot bypass the Process-PSModule trigger, concurrency, permission, or authorization boundary.",
+ });
+ }
+
+ const complete = missingFields.length === 0;
+ const compliant = complete && deltas.length === 0;
+ return {
+ repository: record.repository,
+ workflowPath: record.workflowPath,
+ status: complete
+ ? compliant
+ ? "compliant"
+ : "migration-needed"
+ : "incomplete",
+ compliant,
+ complete,
+ requestReady: complete,
+ missingFields,
+ deltas,
+ preservation,
+ reviewWarnings,
+ };
+}
+
+export function analyzeInventory(records) {
+ return records.map((record) => ({
+ ...record,
+ analysis: compareRepository(record),
+ }));
+}
+
+export function getSummary(state) {
+ const records = state.records ?? [];
+ const analyses = records.map((record) => record.analysis);
+ return {
+ inventoryStatus: state.inventoryStatus,
+ generatedAt: state.generatedAt ?? null,
+ organization: state.organization ?? null,
+ total: records.length,
+ parsed: records.filter((record) => record.status === "parsed").length,
+ parseErrors: analyses.filter((item) => item.status === "parse-error").length,
+ incomplete: analyses.filter((item) => item.status === "incomplete").length,
+ compliant: analyses.filter((item) => item.compliant).length,
+ migrationNeeded: analyses.filter(
+ (item) => item.status === "migration-needed",
+ ).length,
+ requestReady: analyses.filter(
+ (item) => item.requestReady && !item.compliant,
+ ).length,
+ selected: state.selection?.length ?? 0,
+ error: state.error ?? null,
+ };
+}
+
+export function buildMigrationRequest(records) {
+ if (!Array.isArray(records) || records.length === 0) {
+ throw new Error("Select at least one repository before requesting migration.");
+ }
+
+ const blocked = records.filter((record) => !record.analysis.requestReady);
+ if (blocked.length > 0) {
+ throw new Error(
+ `Migration request blocked because inventory is incomplete for: ${blocked
+ .map((record) => record.repository)
+ .join(", ")}.`,
+ );
+ }
+
+ return {
+ schemaVersion: 1,
+ target: TARGET_CONTRACT,
+ repositories: records.map((record) => ({
+ repository: record.repository,
+ defaultBranch: record.defaultBranch,
+ workflowPath: record.workflowPath,
+ workflowUrl: record.workflowUrl,
+ currentReferences: record.processJobs.map((job) => job.reference),
+ deltas: record.analysis.deltas,
+ preserve: record.analysis.preservation,
+ reviewWarnings: record.analysis.reviewWarnings,
+ })),
+ };
+}
+
+export function buildMigrationPrompt(request) {
+ const repositoryNames = request.repositories
+ .map((record) => record.repository)
+ .join(", ");
+ return `Orchestrate the Process-PSModule v8 caller migration for these repositories: ${repositoryNames}.
+
+Invoke the orchestrate skill and follow the MSX fleet orchestration and contribution workflows. Create exactly one coordinated child project session per selected repository. In each repository:
+
+1. Refresh and read the target repository's default-branch caller workflow before editing; do not trust this snapshot as the source of truth.
+2. Create one repository-scoped delivery branch and open one draft pull request early. Adopt a matching open pull request instead of creating a duplicate.
+3. Preserve valid repository-specific schedule timing, optional TestData JSON, and valid ImportantFilePatterns, SettingsPath, WorkingDirectory, Version, Prerelease, or Verbose behavior after verifying each value against the current repository and reusable-workflow interface.
+4. Apply the agreed caller identity, trigger, concurrency, permission, unconditional-call, explicit-secret, and @v8 contract from the structured request below. Do not set Debug: true.
+5. Read every additional repository-owned job and verify it cannot bypass the Process-PSModule trigger, concurrency, permission, or authorization boundary. Stop and report a blocker instead of guessing when inventory or workflow parsing is incomplete.
+6. Run the repository-native validation and the applicable review loop. Report every child session, branch, validation result, blocker, and draft pull request URL to this parent session.
+
+Do not batch repositories into one branch or pull request. Do not mutate repositories from outside their child sessions.
+
+Structured migration request:
+
+\`\`\`json
+${JSON.stringify(request, null, 2)}
+\`\`\``;
+}
diff --git a/.github/extensions/process-workflow-fleet/fleet-model.test.mjs b/.github/extensions/process-workflow-fleet/fleet-model.test.mjs
new file mode 100644
index 00000000..e8d51f96
--- /dev/null
+++ b/.github/extensions/process-workflow-fleet/fleet-model.test.mjs
@@ -0,0 +1,173 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import {
+ analyzeInventory,
+ buildMigrationPrompt,
+ buildMigrationRequest,
+ compareRepository,
+ normalizeInventory,
+} from "./fleet-model.mjs";
+
+function createRecord(overrides = {}) {
+ return {
+ Repository: "PSModule/Example",
+ DefaultBranch: "main",
+ Archived: false,
+ RepositoryUrl: "https://github.com/PSModule/Example",
+ WorkflowPath: ".github/workflows/Process-PSModule.yml",
+ WorkflowUrl:
+ "https://github.com/PSModule/Example/blob/main/.github/workflows/Process-PSModule.yml",
+ Status: "Parsed",
+ Error: null,
+ WorkflowName: "Process-PSModule",
+ RunName: null,
+ Events: ["pull_request", "push", "schedule", "workflow_dispatch"],
+ Schedules: ["0 0 * * *"],
+ PushBranches: ["main"],
+ PushBranchesIgnore: [],
+ PushPaths: [],
+ PushPathsIgnore: [],
+ PullRequestBranches: ["main"],
+ PullRequestTypes: [
+ "closed",
+ "opened",
+ "reopened",
+ "synchronize",
+ "labeled",
+ "unlabeled",
+ ],
+ PullRequestPaths: [],
+ PullRequestPathsIgnore: [],
+ ConcurrencyGroup:
+ "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}",
+ CancelInProgress: "${{ github.event_name == 'pull_request' }}",
+ Permissions: {},
+ ProcessJobs: [
+ {
+ Name: "Process-PSModule",
+ Uses: "PSModule/Process-PSModule/.github/workflows/workflow.yml@v8",
+ Reference: "v8",
+ Inputs: {},
+ SecretMode: "explicit",
+ SecretMappings: {
+ PSGALLERY_API_KEY: "${{ secrets.PSGALLERY_API_KEY }}",
+ GitHubAppClientId: "${{ secrets.SHELLY_CLIENT_ID }}",
+ GitHubAppPrivateKey: "${{ secrets.SHELLY_PRIVATE_KEY }}",
+ },
+ Permissions: {
+ contents: "read",
+ pages: "write",
+ "id-token": "write",
+ },
+ Environment: null,
+ Condition: null,
+ },
+ ],
+ AdditionalJobs: [],
+ VersionComments: [{ Reference: "v8", Version: "v8.0.1" }],
+ ...overrides,
+ };
+}
+
+describe("inventory normalization and comparison", () => {
+ it("recognizes the agreed v8 caller contract", () => {
+ const [record] = normalizeInventory([createRecord()]);
+ const analysis = compareRepository(record);
+
+ assert.equal(record.repository, "PSModule/Example");
+ assert.equal(analysis.status, "compliant");
+ assert.equal(analysis.compliant, true);
+ assert.deepEqual(analysis.deltas, []);
+ });
+
+ it("reports migration deltas and preserves supported optional behavior", () => {
+ const source = createRecord({
+ Events: ["pull_request", "schedule", "workflow_dispatch"],
+ ProcessJobs: [
+ {
+ ...createRecord().ProcessJobs[0],
+ Uses: "PSModule/Process-PSModule/.github/workflows/workflow.yml@v6",
+ Reference: "v6",
+ Inputs: {
+ Debug: true,
+ ImportantFilePatterns: '["src/**","README.md"]',
+ },
+ SecretMappings: {
+ PSGALLERY_API_KEY: "${{ secrets.PSGALLERY_API_KEY }}",
+ GitHubAppClientId: "${{ secrets.SHELLY_CLIENT_ID }}",
+ GitHubAppPrivateKey: "${{ secrets.SHELLY_PRIVATE_KEY }}",
+ TestData:
+ '{"secrets":{"TOKEN":"${{ secrets.TEST_TOKEN }}"}}',
+ },
+ },
+ ],
+ });
+ const [record] = analyzeInventory(normalizeInventory(source));
+
+ assert.equal(record.analysis.status, "migration-needed");
+ assert.ok(
+ record.analysis.deltas.some((delta) => delta.field === "job.uses"),
+ );
+ assert.ok(
+ record.analysis.deltas.some(
+ (delta) => delta.field === "job.inputs.Debug",
+ ),
+ );
+ assert.deepEqual(
+ record.analysis.preservation.map((item) => item.field).sort(),
+ ["job.inputs.ImportantFilePatterns", "job.secrets.TestData"],
+ );
+ });
+
+ it("fails closed when the inventory contract is incomplete", () => {
+ const source = createRecord();
+ delete source.PullRequestPaths;
+ const [record] = analyzeInventory(normalizeInventory(source));
+
+ assert.equal(record.analysis.status, "incomplete");
+ assert.equal(record.analysis.requestReady, false);
+ assert.deepEqual(record.analysis.missingFields, ["PullRequestPaths"]);
+ assert.throws(
+ () => buildMigrationRequest([record]),
+ /inventory is incomplete/,
+ );
+ });
+
+ it("fails closed on parse errors", () => {
+ const [record] = analyzeInventory(
+ normalizeInventory({
+ Repository: "PSModule/Broken",
+ WorkflowPath: ".github/workflows/Process-PSModule.yml",
+ Status: "ParseError",
+ Error: "Unexpected token",
+ }),
+ );
+
+ assert.equal(record.analysis.status, "parse-error");
+ assert.equal(record.analysis.requestReady, false);
+ });
+});
+
+describe("migration request generation", () => {
+ it("creates a complete orchestration prompt without mutating repositories", () => {
+ const [record] = analyzeInventory(normalizeInventory(createRecord()));
+ const request = buildMigrationRequest([record]);
+ const prompt = buildMigrationPrompt(request);
+
+ assert.match(prompt, /exactly one coordinated child project session/);
+ assert.match(prompt, /open one draft pull request early/);
+ assert.match(prompt, /refresh and read/i);
+ assert.match(prompt, /ImportantFilePatterns/);
+ assert.match(prompt, /workflow\.yml@v8/);
+ assert.match(prompt, /repository-native validation/);
+ assert.match(prompt, /PSModule\/Example/);
+ });
+
+ it("rejects an empty repository selection", () => {
+ assert.throws(
+ () => buildMigrationRequest([]),
+ /Select at least one repository/,
+ );
+ });
+});
diff --git a/.github/extensions/process-workflow-fleet/fleet-service.mjs b/.github/extensions/process-workflow-fleet/fleet-service.mjs
new file mode 100644
index 00000000..325795af
--- /dev/null
+++ b/.github/extensions/process-workflow-fleet/fleet-service.mjs
@@ -0,0 +1,695 @@
+import { execFile, execFileSync } from "node:child_process";
+import { createHash, randomBytes } from "node:crypto";
+import {
+ mkdir,
+ readFile,
+ rename,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import {
+ dirname,
+ isAbsolute,
+ join,
+ resolve,
+} from "node:path";
+import { promisify } from "node:util";
+
+import {
+ analyzeInventory,
+ buildMigrationPrompt,
+ buildMigrationRequest,
+ getSummary,
+ normalizeInventory,
+} from "./fleet-model.mjs";
+import { renderDashboard } from "./renderer.mjs";
+
+const execFileAsync = promisify(execFile);
+const STATE_VERSION = 1;
+const MAX_REQUEST_BYTES = 1024 * 1024;
+const servers = new Map();
+const refreshes = new Map();
+
+let createCanvasError = (code, message) => {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+};
+
+function pathLooksLikeRepository(candidate) {
+ try {
+ const root = execFileSync(
+ "git",
+ ["-C", candidate, "rev-parse", "--show-toplevel"],
+ {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ },
+ ).trim();
+ return root || null;
+ } catch (error) {
+ if (
+ error &&
+ typeof error === "object" &&
+ hasOwn(error, "status") &&
+ error.status !== 0
+ ) {
+ return null;
+ }
+ throw error;
+ }
+}
+
+function hasOwn(value, property) {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ Object.prototype.hasOwnProperty.call(value, property)
+ );
+}
+
+export function resolveRepositoryRoot({
+ currentWorkingDirectory,
+ moduleDirectory,
+ sessionWorkspacePath,
+} = {}) {
+ const candidates = [
+ currentWorkingDirectory,
+ process.env.GITHUB_WORKSPACE,
+ process.env.COPILOT_WORKSPACE_PATH,
+ sessionWorkspacePath,
+ moduleDirectory,
+ ].filter(Boolean);
+
+ for (const candidate of candidates) {
+ const root = pathLooksLikeRepository(resolve(candidate));
+ if (root) {
+ return root;
+ }
+ }
+
+ throw new Error(
+ `Could not locate the repository root from: ${candidates.join(", ")}.`,
+ );
+}
+
+function workspaceIdentity(repositoryRoot) {
+ return createHash("sha256")
+ .update(resolve(repositoryRoot).toLowerCase())
+ .digest("hex")
+ .slice(0, 16);
+}
+
+function sanitizeDiagnostic(value) {
+ return String(value ?? "")
+ .replace(
+ /\b(?:gh[opurs]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\b/g,
+ "[REDACTED_TOKEN]",
+ )
+ .replace(
+ /(GH_TOKEN|GITHUB_TOKEN|PSGALLERY_API_KEY)\s*[:=]\s*\S+/gi,
+ "$1=[REDACTED]",
+ )
+ .trim();
+}
+
+function initialState(repositoryRoot, organization = "PSModule") {
+ return {
+ stateVersion: STATE_VERSION,
+ workspaceIdentity: workspaceIdentity(repositoryRoot),
+ repositoryRoot,
+ organization,
+ inventoryStatus: "not-refreshed",
+ generatedAt: null,
+ command: null,
+ error: null,
+ records: [],
+ selection: [],
+ };
+}
+
+function responseJson(response, statusCode, value) {
+ response.writeHead(statusCode, {
+ "Cache-Control": "no-store",
+ "Content-Type": "application/json; charset=utf-8",
+ "X-Content-Type-Options": "nosniff",
+ });
+ response.end(JSON.stringify(value));
+}
+
+function responseHtml(response, html) {
+ response.writeHead(200, {
+ "Cache-Control": "no-store",
+ "Content-Security-Policy":
+ "default-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'self'",
+ "Content-Type": "text/html; charset=utf-8",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ });
+ response.end(html);
+}
+
+async function readRequestJson(request) {
+ let size = 0;
+ const chunks = [];
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > MAX_REQUEST_BYTES) {
+ throw createCanvasError(
+ "request_too_large",
+ `Canvas request exceeds ${MAX_REQUEST_BYTES} bytes.`,
+ );
+ }
+ chunks.push(chunk);
+ }
+ if (chunks.length === 0) {
+ return {};
+ }
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ } catch (error) {
+ throw createCanvasError(
+ "request_json_invalid",
+ `Canvas request JSON is invalid: ${sanitizeDiagnostic(error.message)}`,
+ );
+ }
+}
+
+function requireCanvasToken(request, token) {
+ if (request.headers["x-canvas-token"] !== token) {
+ throw createCanvasError(
+ "canvas_request_forbidden",
+ "Canvas request token is missing or invalid.",
+ );
+ }
+}
+
+async function closeHttpServer(server) {
+ await new Promise((resolveClose, rejectClose) => {
+ server.close((error) => {
+ if (error) {
+ rejectClose(error);
+ } else {
+ resolveClose();
+ }
+ });
+ });
+}
+
+export function createFleetService({ getSession, repositoryRoot }) {
+ const identity = workspaceIdentity(repositoryRoot);
+
+ function stateDirectory() {
+ const sessionWorkspacePath = getSession()?.workspacePath;
+ if (sessionWorkspacePath) {
+ return join(
+ sessionWorkspacePath,
+ "files",
+ "process-workflow-fleet",
+ identity,
+ );
+ }
+ return join(tmpdir(), "copilot-process-workflow-fleet", identity);
+ }
+
+ function statePath() {
+ return join(stateDirectory(), "state.json");
+ }
+
+ function inventoryPath() {
+ return join(stateDirectory(), "inventory.json");
+ }
+
+ async function writeState(state) {
+ const directory = stateDirectory();
+ await mkdir(directory, { recursive: true });
+ const path = statePath();
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
+ try {
+ await writeFile(
+ temporaryPath,
+ `${JSON.stringify(state, null, 2)}\n`,
+ "utf8",
+ );
+ await rename(temporaryPath, path);
+ } finally {
+ await rm(temporaryPath, { force: true });
+ }
+ return state;
+ }
+
+ async function readState() {
+ try {
+ const state = JSON.parse(await readFile(statePath(), "utf8"));
+ if (
+ state.stateVersion !== STATE_VERSION ||
+ state.workspaceIdentity !== identity
+ ) {
+ throw new Error(
+ `Unsupported or mismatched state at ${statePath()}.`,
+ );
+ }
+ if (
+ state.inventoryStatus === "refreshing" &&
+ !refreshes.has(identity)
+ ) {
+ return writeState({
+ ...state,
+ inventoryStatus: "not-refreshed",
+ generatedAt: null,
+ error: {
+ code: "inventory_refresh_interrupted",
+ message:
+ "The previous inventory refresh was interrupted. Refresh again.",
+ },
+ records: [],
+ selection: [],
+ });
+ }
+ return state;
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ return null;
+ }
+ throw createCanvasError(
+ "fleet_state_read_failed",
+ `Could not read workflow fleet state at ${statePath()}: ${sanitizeDiagnostic(error.message)}`,
+ );
+ }
+ }
+
+ async function ensureState({ organization } = {}) {
+ const existing = await readState();
+ if (existing) {
+ if (organization && organization !== existing.organization) {
+ return writeState({
+ ...initialState(repositoryRoot, organization),
+ selection: [],
+ });
+ }
+ return existing;
+ }
+ return writeState(initialState(repositoryRoot, organization));
+ }
+
+ function inventoryCommand(input, outputPath) {
+ const organization = input.organization || "PSModule";
+ const scriptPath = join(
+ repositoryRoot,
+ ".github",
+ "scripts",
+ "Get-ProcessPSModuleWorkflowInventory.ps1",
+ );
+ if (!isAbsolute(scriptPath)) {
+ throw createCanvasError(
+ "inventory_script_path_invalid",
+ `Inventory script path is not absolute: ${scriptPath}`,
+ );
+ }
+ const args = [
+ "-NoLogo",
+ "-NoProfile",
+ "-File",
+ scriptPath,
+ "-Organization",
+ organization,
+ ];
+ if (input.repositories?.length) {
+ args.push("-Repository", ...input.repositories);
+ }
+ args.push("-TargetReference", "v8", "-JsonPath", outputPath);
+ if (input.includeArchived === true) {
+ args.push("-IncludeArchived");
+ }
+ return {
+ executable: "pwsh",
+ args,
+ organization,
+ repositories: input.repositories ?? [],
+ display: `pwsh -NoLogo -NoProfile -File ${scriptPath} -Organization ${organization}${input.repositories?.length ? ` -Repository ${input.repositories.join(",")}` : ""} -TargetReference v8 -JsonPath ${outputPath}${input.includeArchived ? " -IncludeArchived" : ""}`,
+ };
+ }
+
+ async function loadGeneratedInventory() {
+ const content = await readFile(inventoryPath(), "utf8");
+ const parsed = JSON.parse(content.replace(/^\uFEFF/, ""));
+ const records = analyzeInventory(normalizeInventory(parsed));
+ if (records.length === 0) {
+ throw new Error(
+ `Inventory command wrote no workflow records to ${inventoryPath()}.`,
+ );
+ }
+ return records;
+ }
+
+ async function refreshInventory(input = {}) {
+ if (refreshes.has(identity)) {
+ throw createCanvasError(
+ "inventory_refresh_in_progress",
+ "A workflow inventory refresh is already running for this workspace.",
+ );
+ }
+
+ const refresh = (async () => {
+ const previous = await ensureState({
+ organization: input.organization,
+ });
+ const command = inventoryCommand(
+ {
+ ...input,
+ organization: input.organization || previous.organization,
+ },
+ inventoryPath(),
+ );
+ await rm(inventoryPath(), { force: true });
+ await writeState({
+ ...previous,
+ organization: command.organization,
+ inventoryStatus: "refreshing",
+ generatedAt: null,
+ command: command.display,
+ error: null,
+ records: [],
+ selection: [],
+ });
+
+ try {
+ await execFileAsync(command.executable, command.args, {
+ cwd: repositoryRoot,
+ encoding: "utf8",
+ maxBuffer: 10 * 1024 * 1024,
+ windowsHide: true,
+ });
+ const records = await loadGeneratedInventory();
+ const state = await writeState({
+ ...previous,
+ organization: command.organization,
+ inventoryStatus: "ready",
+ generatedAt: new Date().toISOString(),
+ command: command.display,
+ error: null,
+ records,
+ selection: [],
+ });
+ return {
+ summary: getSummary(state),
+ artifactPath: inventoryPath(),
+ };
+ } catch (error) {
+ let records = [];
+ try {
+ records = await loadGeneratedInventory();
+ } catch (artifactError) {
+ if (artifactError?.code !== "ENOENT") {
+ error.message = `${error.message}; generated inventory could not be read: ${artifactError.message}`;
+ }
+ }
+
+ const diagnostic = sanitizeDiagnostic(
+ error.stderr || error.stdout || error.message,
+ );
+ const failure = await writeState({
+ ...previous,
+ organization: command.organization,
+ inventoryStatus: "failed",
+ generatedAt: new Date().toISOString(),
+ command: command.display,
+ error: {
+ code: "inventory_refresh_failed",
+ message: diagnostic,
+ organization: command.organization,
+ repositories: command.repositories,
+ },
+ records,
+ selection: [],
+ });
+ getSession()?.log(
+ `Process workflow fleet refresh failed for ${command.organization}: ${diagnostic}`,
+ { level: "error", ephemeral: false },
+ );
+ throw createCanvasError(
+ "inventory_refresh_failed",
+ `Inventory command failed for ${command.organization}: ${diagnostic}. State: ${statePath()}`,
+ );
+ }
+ })();
+
+ refreshes.set(identity, refresh);
+ try {
+ return await refresh;
+ } finally {
+ refreshes.delete(identity);
+ }
+ }
+
+ async function getSummaryResult() {
+ return getSummary(await ensureState());
+ }
+
+ async function getRepository(repository) {
+ const state = await ensureState();
+ const record = state.records.find(
+ (item) => item.repository === repository,
+ );
+ if (!record) {
+ throw createCanvasError(
+ "repository_not_found",
+ `Repository [${repository}] is not present in the current inventory.`,
+ );
+ }
+ return record;
+ }
+
+ async function setSelection(repositories) {
+ const state = await ensureState();
+ if (state.inventoryStatus !== "ready") {
+ throw createCanvasError(
+ "inventory_not_ready",
+ "Refresh inventory successfully before selecting repositories.",
+ );
+ }
+ const known = new Set(state.records.map((record) => record.repository));
+ const unknown = repositories.filter((repository) => !known.has(repository));
+ if (unknown.length > 0) {
+ throw createCanvasError(
+ "selection_repository_unknown",
+ `Selection contains repositories outside the current inventory: ${unknown.join(", ")}.`,
+ );
+ }
+ const selection = [...new Set(repositories)].sort();
+ await writeState({
+ ...state,
+ selection,
+ });
+ return {
+ repositories: selection,
+ count: selection.length,
+ };
+ }
+
+ async function requestMigration({
+ repositories,
+ dryRun = true,
+ confirmed = false,
+ } = {}) {
+ const state = await ensureState();
+ if (state.inventoryStatus !== "ready") {
+ throw createCanvasError(
+ "inventory_not_ready",
+ "Migration requests require a successful current inventory refresh.",
+ );
+ }
+ const selection = repositories ?? state.selection;
+ if (!selection || selection.length === 0) {
+ throw createCanvasError(
+ "migration_selection_empty",
+ "Select at least one repository before requesting migration.",
+ );
+ }
+ const selected = selection.map((repository) => {
+ const record = state.records.find(
+ (item) => item.repository === repository,
+ );
+ if (!record) {
+ throw createCanvasError(
+ "migration_repository_unknown",
+ `Repository [${repository}] is not present in the current inventory.`,
+ );
+ }
+ return record;
+ });
+
+ let request;
+ try {
+ request = buildMigrationRequest(selected);
+ } catch (error) {
+ throw createCanvasError(
+ "migration_inventory_incomplete",
+ error.message,
+ );
+ }
+ const prompt = buildMigrationPrompt(request);
+
+ if (dryRun !== false) {
+ return {
+ dryRun: true,
+ sent: false,
+ request,
+ prompt,
+ };
+ }
+ if (confirmed !== true) {
+ throw createCanvasError(
+ "migration_confirmation_required",
+ "Set confirmed to true only after the user explicitly confirms the migration request.",
+ );
+ }
+
+ const activeSession = getSession();
+ if (!activeSession) {
+ throw createCanvasError(
+ "session_unavailable",
+ "The active Copilot session is not available for migration orchestration.",
+ );
+ }
+ const messageId = await activeSession.send({ prompt });
+ return {
+ dryRun: false,
+ sent: true,
+ messageId,
+ repositories: selection,
+ };
+ }
+
+ async function handleHttpRequest(request, response, token, organization) {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+ if (request.method === "GET" && url.pathname === "/") {
+ responseHtml(response, renderDashboard(token));
+ return;
+ }
+ if (request.method === "GET" && url.pathname === "/api/state") {
+ responseJson(response, 200, await ensureState({ organization }));
+ return;
+ }
+ if (request.method !== "POST") {
+ responseJson(response, 404, {
+ error: {
+ code: "route_not_found",
+ message: `No canvas route for ${request.method} ${url.pathname}.`,
+ },
+ });
+ return;
+ }
+
+ requireCanvasToken(request, token);
+ const input = await readRequestJson(request);
+ if (url.pathname === "/api/refresh") {
+ responseJson(response, 200, await refreshInventory(input));
+ return;
+ }
+ if (url.pathname === "/api/selection") {
+ responseJson(
+ response,
+ 200,
+ await setSelection(input.repositories ?? []),
+ );
+ return;
+ }
+ if (url.pathname === "/api/migration") {
+ responseJson(response, 200, await requestMigration(input));
+ return;
+ }
+ responseJson(response, 404, {
+ error: {
+ code: "route_not_found",
+ message: `No canvas route for POST ${url.pathname}.`,
+ },
+ });
+ }
+
+ async function openPanel(instanceId, { organization } = {}) {
+ const existing = servers.get(instanceId);
+ if (existing) {
+ return existing;
+ }
+
+ const token = randomBytes(24).toString("base64url");
+ const server = createServer((request, response) => {
+ handleHttpRequest(request, response, token, organization).catch(
+ (error) => {
+ const code = error.code || "canvas_http_failed";
+ const message = sanitizeDiagnostic(error.message);
+ getSession()?.log(
+ `Process workflow fleet request failed: ${message}`,
+ { level: "error", ephemeral: true },
+ );
+ if (!response.headersSent) {
+ responseJson(
+ response,
+ code === "route_not_found" ? 404 : 500,
+ {
+ error: { code, message },
+ },
+ );
+ } else {
+ response.end();
+ }
+ },
+ );
+ });
+ server.on("clientError", (error, socket) => {
+ getSession()?.log(
+ `Process workflow fleet HTTP client error: ${sanitizeDiagnostic(error.message)}`,
+ { level: "warning", ephemeral: true },
+ );
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
+ });
+ await new Promise((resolveListen, rejectListen) => {
+ server.once("error", rejectListen);
+ server.listen(0, "127.0.0.1", () => {
+ server.off("error", rejectListen);
+ resolveListen();
+ });
+ });
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ await closeHttpServer(server);
+ throw createCanvasError(
+ "canvas_server_address_invalid",
+ "Loopback canvas server did not return a TCP address.",
+ );
+ }
+ const entry = {
+ server,
+ token,
+ url: `http://127.0.0.1:${address.port}/`,
+ };
+ servers.set(instanceId, entry);
+ return entry;
+ }
+
+ async function closePanel(instanceId) {
+ const entry = servers.get(instanceId);
+ if (!entry) {
+ return;
+ }
+ servers.delete(instanceId);
+ await closeHttpServer(entry.server);
+ }
+
+ return {
+ closePanel,
+ ensureState,
+ getRepository,
+ getSummary: getSummaryResult,
+ openPanel,
+ refreshInventory,
+ requestMigration,
+ setCanvasErrorFactory(factory) {
+ createCanvasError = factory;
+ },
+ setSelection,
+ };
+}
diff --git a/.github/extensions/process-workflow-fleet/renderer.mjs b/.github/extensions/process-workflow-fleet/renderer.mjs
new file mode 100644
index 00000000..83b8570b
--- /dev/null
+++ b/.github/extensions/process-workflow-fleet/renderer.mjs
@@ -0,0 +1,714 @@
+function serializeForInlineScript(value) {
+ return JSON.stringify(value).replaceAll("<", "\\u003c");
+}
+
+export function renderDashboard(token) {
+ return `
+
+
+
+
+ Process workflow fleet
+
+
+
+
+
+
Loading workspace state…
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Repository |
+ Reference / compliance |
+ Triggers |
+ Concurrency |
+ Permissions |
+ Condition |
+ Secrets |
+ Inputs |
+ Extra jobs |
+
+
+
+
+
No repositories match this view.
+
+
+
+
+
+
+
+
+`;
+}
diff --git a/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1 b/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1
index d34fd3b9..709d5dec 100644
--- a/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1
+++ b/.github/scripts/Get-ProcessPSModuleWorkflowInventory.ps1
@@ -590,6 +590,7 @@ function Get-WorkflowInventoryItem {
} else {
[ordered]@{}
}
+ Permissions = ConvertTo-PermissionValue -Value (Get-MapValue -Map $job -Name 'permissions')
Environment = Get-MapValue -Map $job -Name 'environment'
Condition = Get-MapValue -Map $job -Name 'if'
}
@@ -658,33 +659,35 @@ function Get-WorkflowInventoryItem {
)
[pscustomobject]@{
- Repository = $WorkflowFile.Repository
- DefaultBranch = $WorkflowFile.DefaultBranch
- Archived = $WorkflowFile.Archived
- RepositoryUrl = $WorkflowFile.RepositoryUrl
- WorkflowPath = $WorkflowFile.WorkflowPath
- WorkflowUrl = $WorkflowFile.WorkflowUrl
- SearchQuery = $WorkflowFile.SearchQuery
- Status = 'Parsed'
- Error = $null
- WorkflowName = Get-MapValue -Map $workflow -Name 'name'
- RunName = Get-MapValue -Map $workflow -Name 'run-name'
- Events = @(Get-MapKey -Map $trigger | Sort-Object)
- Schedules = @($schedule | ForEach-Object { Get-MapValue -Map $_ -Name 'cron' })
- PushBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches')
- PushBranchesIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches-ignore')
- PushPaths = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths')
- PushPathsIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths-ignore')
- PullRequestBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'branches')
- PullRequestTypes = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'types')
- ConcurrencyGroup = $concurrencyGroup
- CancelInProgress = $cancelInProgress
- Permissions = $permissions
- ProcessJobs = @($processJobs)
- AdditionalJobs = @($allJobNames | Where-Object { $_ -notin $processJobNames })
- VersionComments = $versionComments
- TargetReference = $ExpectedTargetReference
- MatchesTarget = if ($ExpectedTargetReference) {
+ Repository = $WorkflowFile.Repository
+ DefaultBranch = $WorkflowFile.DefaultBranch
+ Archived = $WorkflowFile.Archived
+ RepositoryUrl = $WorkflowFile.RepositoryUrl
+ WorkflowPath = $WorkflowFile.WorkflowPath
+ WorkflowUrl = $WorkflowFile.WorkflowUrl
+ SearchQuery = $WorkflowFile.SearchQuery
+ Status = 'Parsed'
+ Error = $null
+ WorkflowName = Get-MapValue -Map $workflow -Name 'name'
+ RunName = Get-MapValue -Map $workflow -Name 'run-name'
+ Events = @(Get-MapKey -Map $trigger | Sort-Object)
+ Schedules = @($schedule | ForEach-Object { Get-MapValue -Map $_ -Name 'cron' })
+ PushBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches')
+ PushBranchesIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'branches-ignore')
+ PushPaths = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths')
+ PushPathsIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $push -Name 'paths-ignore')
+ PullRequestBranches = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'branches')
+ PullRequestTypes = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'types')
+ PullRequestPaths = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'paths')
+ PullRequestPathsIgnore = ConvertTo-StringArray -Value (Get-MapValue -Map $pullRequest -Name 'paths-ignore')
+ ConcurrencyGroup = $concurrencyGroup
+ CancelInProgress = $cancelInProgress
+ Permissions = $permissions
+ ProcessJobs = @($processJobs)
+ AdditionalJobs = @($allJobNames | Where-Object { $_ -notin $processJobNames })
+ VersionComments = $versionComments
+ TargetReference = $ExpectedTargetReference
+ MatchesTarget = if ($ExpectedTargetReference) {
@($processJobs | Where-Object { -not $_.MatchesTarget }).Count -eq 0
} else {
$null
diff --git a/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1 b/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1
index a834d138..d772f266 100644
--- a/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1
+++ b/.github/scripts/tests/Get-ProcessPSModuleWorkflowInventory.Tests.ps1
@@ -40,6 +40,10 @@ permissions:
jobs:
Process-PSModule:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8
with:
Debug: true
@@ -95,12 +99,17 @@ Describe 'Get-ProcessPSModuleWorkflowInventory' {
$result[0].Events | Should -Be @('pull_request', 'push', 'schedule', 'workflow_dispatch')
$result[0].PushBranches | Should -Be @('main')
$result[0].PullRequestTypes | Should -Be @('opened', 'synchronize')
+ $result[0].PullRequestPaths | Should -BeNullOrEmpty
+ $result[0].PullRequestPathsIgnore | Should -BeNullOrEmpty
$result[0].ConcurrencyGroup | Should -Be '${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}'
$result[0].CancelInProgress | Should -BeFalse
$result[0].ProcessJobs[0].Reference | Should -Be 'v8'
$result[0].ProcessJobs[0].MatchesTarget | Should -BeTrue
$result[0].MatchesTarget | Should -BeTrue
$result[0].ProcessJobs[0].Condition | Should -Match 'head.repo.full_name'
+ $result[0].ProcessJobs[0].Permissions.contents | Should -Be 'read'
+ $result[0].ProcessJobs[0].Permissions.pages | Should -Be 'write'
+ $result[0].ProcessJobs[0].Permissions.'id-token' | Should -Be 'write'
$result[0].ProcessJobs[0].Inputs.Keys | Should -Contain 'Debug'
$result[0].ProcessJobs[0].SecretMappings.Keys | Should -Be @(
'PSGALLERY_API_KEY'