Public/New-PckKnowledgeSource.ps1

function New-PckKnowledgeSource {
    <#
    .SYNOPSIS
    Wires a Dataverse knowledge source into a Copilot Studio agent: the one
    thing pac cannot do at all.

    .DESCRIPTION
    Creates the four records that make a Dataverse knowledge source (design
    6.3), every call inside the solution: the dvtablesearch configuration, one
    dvtablesearchentity per table, the componenttype 16 botcomponent whose
    schemaname is built from the agent's read (never guessed) schemaname and
    whose parentbotid lookup attaches it to the agent, and the
    botcomponent_dvtablesearch association.

    Preflight refuses before anything is created: standard harness only (war
    story 2), Integrated end-user auth only (war story 3), solution must exist
    (war story 5), tables must exist, and the search configuration name must be
    unused. A mid-chain failure rolls back every record already created.

    Two things this cmdlet reports rather than solves, honestly: the org-level
    search flag (Enable-PckDataverseSearch) and the find columns on each
    table's Quick Find view, which no Web API route reliably updates (war
    story 1). Grounding is proven only by Wait-PckDataverseSearchReady.

    .EXAMPLE
    New-PckKnowledgeSource -BotName 'Workbench Support Assistant 2.0' `
        -Table wrk_caseresolution -SolutionName WorkbenchSupportAssistant `
        -DisplayName 'Curated Case Resolutions'
    #>

    [CmdletBinding()]
    param(
        [guid] $BotId,

        # Exact display name or schemaname; must match exactly one agent.
        [string] $BotName,

        [Parameter(Mandatory)]
        [string[]] $Table,

        [Parameter(Mandatory)]
        [string] $SolutionName,

        # Friendly name makers see in the Knowledge tab. Deliberately distinct
        # from the machine-style SearchName (design 6.3).
        [string] $DisplayName,

        # Machine-style dvtablesearch name, referenced verbatim by the component
        # YAML. Defaults to '<first table>_search'.
        [string] $SearchName,

        # Tail of the component schemaname '<botSchemaName>.knowledge.<this>'.
        # Defaults to the first table's logical name.
        [string] $ComponentName,

        [switch] $Json
    )

    if (-not $BotId -and [string]::IsNullOrWhiteSpace($BotName)) {
        throw [PckError]::new(
            'Pass -BotId or -BotName to identify the agent.',
            $script:PckExitCode.General)
    }

    if ([string]::IsNullOrWhiteSpace($SearchName)) { $SearchName = "$($Table[0])_search" }
    if ([string]::IsNullOrWhiteSpace($ComponentName)) { $ComponentName = $Table[0] }
    if ([string]::IsNullOrWhiteSpace($DisplayName)) { $DisplayName = "Knowledge: $($Table[0])" }

    # These values land in OData filters, schemanames, and YAML; refuse anything
    # that is not a machine name before it can rewrite a query.
    foreach ($value in (@($SearchName, $ComponentName) + $Table)) {
        if ($value -notmatch '^[A-Za-z][A-Za-z0-9_]*$') {
            throw [PckError]::new(
                "'$value' is not a valid machine name (letters, digits, underscores, starting with a letter).",
                $script:PckExitCode.General)
        }
    }

    # ── Preflight: refuse before anything exists ─────────────────────────────
    $agents = if ($BotId) { @(Get-PckAgentInfo -BotId $BotId) } else { @(Get-PckAgentInfo -Name $BotName) }
    if ($agents.Count -eq 0) {
        throw [PckError]::new(
            "No agent matches '$(if ($BotId) { $BotId } else { $BotName })' in the connected environment.",
            $script:PckExitCode.General)
    }
    if ($agents.Count -gt 1) {
        throw [PckError]::new(
            "'$BotName' matches $($agents.Count) agents ($(@($agents.Name) -join ', ')). Pass -BotId or an exact name.",
            $script:PckExitCode.General)
    }
    $agent = $agents[0]

    Assert-PckAgentHarness -Agent $agent
    Assert-PckAgentAuthMode -Agent $agent
    Assert-PckSolutionExists -SolutionName $SolutionName

    foreach ($t in $Table) {
        try {
            Invoke-PckDataverseRequest -Method Get -Path "EntityDefinitions(LogicalName='$t')?`$select=LogicalName" | Out-Null
        }
        catch {
            throw [PckError]::new(
                "Table '$t' does not exist in the connected environment.",
                $script:PckExitCode.General)
        }
    }

    $dup = @((Invoke-PckDataverseRequest -Method Get `
        -Path "dvtablesearchs?`$select=dvtablesearchid,name&`$filter=name eq '$SearchName'").value)
    if ($dup.Count -gt 0) {
        throw [PckError]::new(
            "A dvtablesearch named '$SearchName' already exists. Delete it or pass a different -SearchName; reusing one is not verified behavior.",
            $script:PckExitCode.General)
    }

    # ── Honest reporting: preconditions this cmdlet does not solve ───────────
    $warnings = [System.Collections.Generic.List[string]]::new()

    $org = @((Invoke-PckDataverseRequest -Method Get `
        -Path 'organizations?$select=organizationid,isexternalsearchindexenabled').value)[0]
    $searchEnabled = [bool]$org.isexternalsearchindexenabled
    if (-not $searchEnabled) {
        $warnings.Add('Dataverse search is disabled on this environment. Nothing grounds until Enable-PckDataverseSearch runs and index provisioning (hours on a fresh environment) completes.')
    }

    $findColumns = @{}
    foreach ($t in $Table) {
        $qf = Get-PckQuickFindColumns -TableLogicalName $t
        if (-not $qf) {
            $warnings.Add("Table '$t' has no Quick Find view, so Dataverse search has no find columns to index for it.")
            continue
        }
        $findColumns[$t] = @($qf.FindColumns)
        if (@($qf.FindColumns).Count -le 1) {
            $warnings.Add("Table '$t' Quick Find view searches only $(@($qf.FindColumns) -join ', '); other columns are invisible to the agent until find columns are added. No Web API route reliably updates this (war story 1): use solution surgery or the maker portal.")
        }
    }

    # ── Create, with best-effort rollback on mid-chain failure ───────────────
    $created = [System.Collections.Generic.List[string]]::new()
    try {
        $search = Invoke-PckDataverseRequest -Method Post -Path 'dvtablesearchs' `
            -Body @{ name = $SearchName } -SolutionName $SolutionName
        $created.Add("dvtablesearchs($($search.EntityId))")

        $entityIds = @(foreach ($t in $Table) {
            $row = Invoke-PckDataverseRequest -Method Post -Path 'dvtablesearchentities' -Body @{
                entitylogicalname          = $t
                'DVTableSearch@odata.bind' = "/dvtablesearchs($($search.EntityId))"
            } -SolutionName $SolutionName
            $created.Add("dvtablesearchentities($($row.EntityId))")
            $row.EntityId
        })

        $componentSchemaName = "$($agent.SchemaName).knowledge.$ComponentName"
        $component = Invoke-PckDataverseRequest -Method Post -Path 'botcomponents' -Body @{
            name                     = $DisplayName
            componenttype            = 16
            schemaname               = $componentSchemaName
            data                     = "kind: KnowledgeSourceConfiguration`nsource:`n kind: DataverseStructuredSearchSource`n skillConfiguration: $SearchName`n"
            'parentbotid@odata.bind' = "/bots($($agent.BotId))"
        } -SolutionName $SolutionName
        $created.Add("botcomponents($($component.EntityId))")

        Invoke-PckDataverseRequest -Method Post `
            -Path "botcomponents($($component.EntityId))/botcomponent_dvtablesearch/`$ref" `
            -Body @{ '@odata.id' = "$($script:PckContext.EnvironmentUrl)/api/data/$($script:PckApiVersion)/dvtablesearchs($($search.EntityId))" } `
            -SolutionName $SolutionName | Out-Null
    }
    catch {
        for ($i = $created.Count - 1; $i -ge 0; $i--) {
            try {
                Invoke-PckDataverseRequest -Method Delete -Path $created[$i] | Out-Null
            }
            catch {
                Write-Warning "Rollback of $($created[$i]) failed: $($_.Exception.Message). Clean it up manually."
            }
        }
        throw
    }

    foreach ($w in $warnings) { Write-Warning $w }

    $result = [pscustomobject]@{
        KnowledgeSourceId      = $component.EntityId
        DisplayName            = $DisplayName
        SchemaName             = $componentSchemaName
        DVTableSearchId        = $search.EntityId
        DVTableSearchEntityIds = $entityIds
        SearchName             = $SearchName
        Tables                 = @($Table)
        BotId                  = $agent.BotId
        BotName                = $agent.Name
        SolutionName           = $SolutionName
        SearchEnabled          = $searchEnabled
        FindColumns            = $findColumns
        Warnings               = @($warnings)
    }
    if ($Json) { $result | ConvertTo-Json -Depth 6 } else { $result }
}