diff --git a/src/CopilotStudio.McsCore/CopilotStudio.McsCore.csproj b/src/CopilotStudio.McsCore/CopilotStudio.McsCore.csproj
index 3785141..c182a21 100644
--- a/src/CopilotStudio.McsCore/CopilotStudio.McsCore.csproj
+++ b/src/CopilotStudio.McsCore/CopilotStudio.McsCore.csproj
@@ -17,6 +17,7 @@
+
diff --git a/src/CopilotStudio.Sync.UnitTests/AiPromptPushTests.cs b/src/CopilotStudio.Sync.UnitTests/AiPromptPushTests.cs
new file mode 100644
index 0000000..f2c3b4a
--- /dev/null
+++ b/src/CopilotStudio.Sync.UnitTests/AiPromptPushTests.cs
@@ -0,0 +1,131 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using Microsoft.CopilotStudio.McsCore;
+using Microsoft.CopilotStudio.Sync.Dataverse;
+using Moq;
+using Xunit;
+
+namespace Microsoft.CopilotStudio.Sync.UnitTests;
+
+public class AiPromptPushTests : IDisposable
+{
+ private readonly string _root;
+ private readonly DirectoryPath _workspace;
+ private readonly Guid _modelId = Guid.NewGuid();
+ private readonly string _promptFolder;
+
+ public AiPromptPushTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "mcs-aiprompt-perf-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ _workspace = new DirectoryPath(_root.Replace('\\', '/') + "/");
+ _promptFolder = Path.Combine(_root, "prompts", "MyPrompt-" + _modelId.ToString("D"));
+ Directory.CreateDirectory(_promptFolder);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+
+ private static WorkspaceSynchronizer CreateSynchronizer()
+ {
+ var fileParser = new SyncMcsFileParser(LspProjectorService.Instance);
+ var fileAccessorFactory = new FileAccessorFactory();
+ var island = new Mock();
+ var progress = new TestSyncProgress(new List());
+ var pathResolver = new LspComponentPathResolver();
+
+ return new WorkspaceSynchronizer(fileParser, fileAccessorFactory, island.Object, progress, pathResolver);
+ }
+
+ private void WritePromptFiles(string instruction)
+ {
+ File.WriteAllText(Path.Combine(_promptFolder, "metadata.yml"), "name: My Prompt\n");
+ File.WriteAllText(Path.Combine(_promptFolder, "prompt.json"), "{ \"instruction\": \"" + instruction + "\" }");
+ }
+
+ [Fact]
+ public async Task UpsertAIPromptsForAgentAsync_UnchangedSinceLastPush_SkipsUpsert()
+ {
+ var synchronizer = CreateSynchronizer();
+ WritePromptFiles("Summarize the input.");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpsertAIPromptAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new SyncDataverseClient.AIPromptResponse { PromptName = "My Prompt", ErrorMessage = string.Empty });
+
+ await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+ await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.UpsertAIPromptAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task UpsertAIPromptsForAgentAsync_UnchangedSinceLastPush_StillReturnsMetadataForCache()
+ {
+ var synchronizer = CreateSynchronizer();
+ WritePromptFiles("Summarize the input.");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpsertAIPromptAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new SyncDataverseClient.AIPromptResponse { PromptName = "My Prompt", ErrorMessage = string.Empty });
+
+ await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+ var (responses, prompts) = await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+
+ Assert.Empty(responses);
+ var metadata = Assert.Single(prompts);
+ Assert.Equal(_modelId, metadata.AIModelId);
+ }
+
+ [Fact]
+ public async Task UpsertAIPromptsForAgentAsync_PromptContentChanged_ReUpserts()
+ {
+ var synchronizer = CreateSynchronizer();
+ WritePromptFiles("Summarize the input.");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpsertAIPromptAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new SyncDataverseClient.AIPromptResponse { PromptName = "My Prompt", ErrorMessage = string.Empty });
+
+ await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+
+ WritePromptFiles("Translate the input to French.");
+ await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.UpsertAIPromptAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task UpsertAIPromptsForAgentAsync_PublishFails_DoesNotRecordBaseline()
+ {
+ var synchronizer = CreateSynchronizer();
+ WritePromptFiles("Summarize the input.");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpsertAIPromptAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new SyncDataverseClient.AIPromptResponse { PromptName = "My Prompt", ErrorMessage = "boom" });
+
+ await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+ await synchronizer.UpsertAIPromptsForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.UpsertAIPromptAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Exactly(2));
+ }
+}
diff --git a/src/CopilotStudio.Sync.UnitTests/ConnectionManagementTests.cs b/src/CopilotStudio.Sync.UnitTests/ConnectionManagementTests.cs
new file mode 100644
index 0000000..0dde66e
--- /dev/null
+++ b/src/CopilotStudio.Sync.UnitTests/ConnectionManagementTests.cs
@@ -0,0 +1,705 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using Microsoft.Agents.ObjectModel;
+using Microsoft.Agents.ObjectModel.Yaml;
+using Microsoft.CopilotStudio.McsCore;
+using Microsoft.CopilotStudio.Sync.Dataverse;
+using Moq;
+using Xunit;
+
+namespace Microsoft.CopilotStudio.Sync.UnitTests;
+
+public class ConnectionManagementTests
+{
+ private static readonly DirectoryPath Workspace = new("c:/test/workspace/");
+
+ private static void Write(InMemoryFileAccessor accessor, string relativePath, string content)
+ {
+ using var stream = accessor.OpenWrite(new AgentFilePath(relativePath));
+ using var writer = new StreamWriter(stream);
+ writer.Write(content);
+ }
+
+ private static void WriteClassicConnectionReferences(InMemoryFileAccessor accessor, params (string logicalName, string connectorId)[] refs)
+ {
+ var list = refs.Select(r => new ConnectionReference.Builder
+ {
+ ConnectionReferenceLogicalName = r.logicalName,
+ ConnectorId = r.connectorId,
+ }.Build()).ToList();
+
+ using var stream = accessor.OpenWrite(new AgentFilePath("connectionreferences.mcs.yml"));
+ using var writer = new StreamWriter(stream);
+ using var ctx = YamlSerializationContext.UseStandardSerializationContextIfNotDefined(throwOnInvalidYaml: false);
+ CodeSerializer.SerializeConnectionReferences(writer, list);
+ }
+
+ [Fact]
+ public void GetWorkflowStatusViews_AllReferencesBound_CanEnable()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ Write(
+ accessor,
+ "workflows/notify/metadata.yml",
+ "name: Notify Flow\nworkflowId: 11111111-1111-1111-1111-111111111111\nstateCode: 0\nstatusCode: 1\nconnectionReferences:\n - cr_office365\n");
+
+ var views = new[]
+ {
+ new AgentConnectionView { ConnectionReferenceLogicalName = "cr_office365", BoundConnectionExists = true },
+ };
+
+ var workflows = synchronizer.GetWorkflowStatusViews(Workspace, views);
+
+ var workflow = Assert.Single(workflows);
+ Assert.Equal(WorkflowState.Draft, workflow.State);
+ Assert.True(workflow.CanEnable);
+ }
+
+ [Fact]
+ public void GetWorkflowStatusViews_UnboundReference_CannotEnable()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ Write(
+ accessor,
+ "workflows/notify/metadata.yml",
+ "name: Notify Flow\nworkflowId: 22222222-2222-2222-2222-222222222222\nstateCode: 0\nstatusCode: 1\nconnectionReferences:\n - cr_office365\n");
+
+ var views = new[]
+ {
+ new AgentConnectionView { ConnectionReferenceLogicalName = "cr_office365", BoundConnectionExists = false },
+ };
+
+ var workflows = synchronizer.GetWorkflowStatusViews(Workspace, views);
+
+ var workflow = Assert.Single(workflows);
+ Assert.False(workflow.CanEnable);
+ }
+
+ [Fact]
+ public void TryWriteConnectionsCache_StaleGenerationAfterMutatingWrite_DoesNotOverwrite()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+
+ var staleGeneration = synchronizer.GetConnectionsCacheGeneration(Workspace);
+
+ var freshViews = new[]
+ {
+ new AgentConnectionView { ConnectionReferenceLogicalName = "cr_fresh", BoundConnectionExists = true },
+ };
+ synchronizer.WriteConnectionsCache(Workspace, freshViews);
+
+ var staleViews = new[]
+ {
+ new AgentConnectionView { ConnectionReferenceLogicalName = "cr_stale", BoundConnectionExists = false },
+ };
+ var wrote = synchronizer.TryWriteConnectionsCache(Workspace, staleViews, staleGeneration);
+
+ Assert.False(wrote);
+ var cache = synchronizer.ReadConnectionsCache(Workspace);
+ Assert.NotNull(cache);
+ var view = Assert.Single(cache!.Connections);
+ Assert.Equal("cr_fresh", view.ConnectionReferenceLogicalName);
+ }
+
+ [Fact]
+ public void TryWriteConnectionsCache_CurrentGeneration_WritesAndAdvancesGeneration()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+
+ var generation = synchronizer.GetConnectionsCacheGeneration(Workspace);
+ var views = new[]
+ {
+ new AgentConnectionView { ConnectionReferenceLogicalName = "cr_office365", BoundConnectionExists = true },
+ };
+
+ var wrote = synchronizer.TryWriteConnectionsCache(Workspace, views, generation);
+
+ Assert.True(wrote);
+ Assert.NotEqual(generation, synchronizer.GetConnectionsCacheGeneration(Workspace));
+ var cache = synchronizer.ReadConnectionsCache(Workspace);
+ Assert.NotNull(cache);
+ Assert.Equal("cr_office365", Assert.Single(cache!.Connections).ConnectionReferenceLogicalName);
+ }
+
+ [Fact]
+ public async Task SetWorkflowActivationsAsync_SingleUnknownWorkflow_FailsWithoutCallingDataverse()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+ var dataverse = new Mock();
+
+ var result = await synchronizer.SetWorkflowActivationsAsync(
+ Workspace,
+ new[] { new WorkflowActivationRequest { WorkflowId = Guid.NewGuid(), Activate = true } },
+ dataverse.Object,
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains("not found", result.Message);
+ dataverse.Verify(
+ c => c.SetWorkflowStateAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task SetWorkflowActivationsAsync_EmptyRequests_NoDataverseCallAndSucceeds()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+ var dataverse = new Mock();
+
+ var result = await synchronizer.SetWorkflowActivationsAsync(
+ Workspace,
+ System.Array.Empty(),
+ dataverse.Object,
+ CancellationToken.None);
+
+ Assert.True(result.Succeeded);
+ dataverse.Verify(
+ c => c.SetWorkflowStateAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task SetWorkflowActivationsAsync_UnknownWorkflows_SkipsAllAndReportsFailureWithoutDataverse()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+ var dataverse = new Mock();
+
+ var requests = new[]
+ {
+ new WorkflowActivationRequest { WorkflowId = Guid.NewGuid(), Activate = true },
+ new WorkflowActivationRequest { WorkflowId = Guid.NewGuid(), Activate = true },
+ };
+
+ var result = await synchronizer.SetWorkflowActivationsAsync(
+ Workspace,
+ requests,
+ dataverse.Object,
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.Contains("not found", result.Message);
+ dataverse.Verify(
+ c => c.SetWorkflowStateAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task SetWorkflowActivationsAsync_EnableReturnsConnectionAuthorizationError_KeepsDraftAndReportsFailure()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ var workflowId = Guid.NewGuid();
+ Write(
+ accessor,
+ "workflows/notify/metadata.yml",
+ $"name: Notify Flow\nworkflowId: {workflowId}\nstateCode: 0\nstatusCode: 1\n");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.SetWorkflowStateAsync(workflowId, true, It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Dataverse request failed (400): {\"error\":{\"code\":\"0x80060467\",\"message\":\"ConnectionAuthorizationFailed: connection cannot be used to activate this flow\"}}"));
+
+ var result = await synchronizer.SetWorkflowActivationsAsync(
+ Workspace,
+ new[] { new WorkflowActivationRequest { WorkflowId = workflowId, Activate = true } },
+ dataverse.Object,
+ CancellationToken.None);
+
+ Assert.False(result.Succeeded);
+ Assert.False(string.IsNullOrEmpty(result.Message));
+ Assert.Contains("draft", result.Message!, StringComparison.OrdinalIgnoreCase);
+ var workflow = Assert.Single(result.Workflows);
+ Assert.Equal(workflowId.ToString(), workflow.WorkflowId);
+ Assert.Equal(WorkflowState.Draft, workflow.State);
+ }
+
+ [Fact]
+ public async Task RemoveConnectionReferenceAsync_WithUsages_Unconfirmed_ReturnsBlockingUsages()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ Write(accessor, "actions/sendmail.mcs.yml", "kind: TaskDialog\nconnectionReference: cr_office365\n");
+
+ var result = await synchronizer.RemoveConnectionReferenceAsync(
+ Workspace, new BotDefinition(), "cr_office365", confirmed: false, CancellationToken.None);
+
+ Assert.False(result.Removed);
+ var usage = Assert.Single(result.Usages);
+ Assert.Equal(UsageKind.Action, usage.Kind);
+ Assert.Equal("actions/sendmail.mcs.yml", usage.FilePath);
+ }
+
+ [Fact]
+ public async Task RemoveConnectionReferenceAsync_Declared_RemovesFromLocalFile()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.keep", "/providers/Microsoft.PowerApps/apis/shared_office365"),
+ ("cree9_agent.shared_teams.drop", "/providers/Microsoft.PowerApps/apis/shared_teams"));
+
+ var result = await synchronizer.RemoveConnectionReferenceAsync(
+ Workspace, new BotDefinition(), "cree9_agent.shared_teams.drop", confirmed: true, CancellationToken.None);
+
+ Assert.True(result.Removed);
+ Assert.False(accessor.Exists(new AgentFilePath(".mcs/botdefinition.json")));
+ using var stream = accessor.OpenRead(new AgentFilePath("connectionreferences.mcs.yml"));
+ using var reader = new StreamReader(stream);
+ var content = reader.ReadToEnd();
+ Assert.Contains("cree9_agent.shared_office365.keep", content);
+ Assert.DoesNotContain("cree9_agent.shared_teams.drop", content);
+ }
+
+ [Fact]
+ public async Task RemoveConnectionReferenceAsync_NoDeclaredReferences_DoesNotRemove()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+
+ var result = await synchronizer.RemoveConnectionReferenceAsync(
+ Workspace, new BotDefinition(), "cr_unused", confirmed: true, CancellationToken.None);
+
+ Assert.False(result.Removed);
+ }
+
+ [Fact]
+ public async Task DeclareConnectionReferencesAsync_NameWithoutConnectorSegment_ReturnsInvalidAndDoesNotProvision()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+ var dataverse = new Mock();
+
+ var result = await synchronizer.DeclareConnectionReferencesAsync(
+ Workspace,
+ new BotDefinition(),
+ new[] { "cre98_AgentB4CC.mail.abc123" },
+ dataverse.Object,
+ CancellationToken.None);
+
+ Assert.Empty(result.Declared);
+ var invalid = Assert.Single(result.Invalid);
+ Assert.Equal("cre98_AgentB4CC.mail.abc123", invalid);
+ dataverse.Verify(
+ c => c.EnsureConnectionReferenceExistsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task CreateConnectionReferenceForConnectorAsync_MintsLogicalNameAndProvisions()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ factory.Create(Workspace);
+ var dataverse = new Mock();
+
+ var logicalName = await synchronizer.CreateConnectionReferenceForConnectorAsync(
+ Workspace,
+ new BotDefinition(),
+ "shared_office365",
+ dataverse.Object,
+ CancellationToken.None);
+
+ Assert.Contains(".shared_office365.", logicalName);
+ dataverse.Verify(
+ c => c.EnsureConnectionReferenceExistsAsync(
+ logicalName,
+ "/providers/Microsoft.PowerApps/apis/shared_office365",
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task CreateConnectionReferenceForConnectorAsync_ReusesPrefixFromExistingReference()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_msnweather.abc", "/providers/Microsoft.PowerApps/apis/shared_msnweather"));
+ var dataverse = new Mock();
+
+ var logicalName = await synchronizer.CreateConnectionReferenceForConnectorAsync(
+ Workspace,
+ new BotDefinition(),
+ "shared_sharepointonline",
+ dataverse.Object,
+ CancellationToken.None);
+
+ Assert.StartsWith("cree9_agent.shared_sharepointonline.", logicalName);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_SourcesDeclaredReferencesFromDisk_NotStaleDefinition()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.kept", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var staleDefinition = new BotDefinition().WithConnectionReferences(new List
+ {
+ new ConnectionReference.Builder
+ {
+ ConnectionReferenceLogicalName = "cree9_agent.shared_office365.kept",
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_office365",
+ }.Build(),
+ new ConnectionReference.Builder
+ {
+ ConnectionReferenceLogicalName = "cree9_agent.shared_office365.removed",
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_office365",
+ }.Build(),
+ });
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ var catalog = new Mock();
+ var context = new PowerAppsContext { AccessToken = string.Empty, EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, staleDefinition, dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.Equal("cree9_agent.shared_office365.kept", view.ConnectionReferenceLogicalName);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_WorkflowOnlyReference_ProducesUndeclaredRow()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+
+ Write(
+ accessor,
+ "workflows/notify/metadata.yml",
+ "name: Notify Flow\nworkflowId: 33333333-3333-3333-3333-333333333333\nstateCode: 0\nstatusCode: 1\nconnectionReferences:\n - cree9_agent.shared_office365.wkonly\n");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ var catalog = new Mock();
+ var context = new PowerAppsContext { AccessToken = string.Empty, EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.Equal("cree9_agent.shared_office365.wkonly", view.ConnectionReferenceLogicalName);
+ Assert.Equal("shared_office365", view.ConnectorName);
+ Assert.False(view.IsDeclared);
+ }
+
+ [Fact]
+ public async Task ApplyConnectionBindingsAsync_EnvironmentSpecificCustomConnector_ResolvesConnectorBeforeBind()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+
+ const string logicalName = "cree9_agent.shared_weather-20agent-5f1234567890abcdef.someid";
+ const string staleConnectorId = "/providers/Microsoft.PowerApps/apis/shared_weather-20agent-5f1234567890abcdef";
+ const string resolvedConnectorId = "/providers/Microsoft.PowerApps/apis/shared_weather-20agent-5fabcdef0123456789";
+
+ WriteClassicConnectionReferences(accessor, (logicalName, staleConnectorId));
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+ dataverse
+ .Setup(c => c.GetConnectorsByInternalIdPrefixAsync("shared_weather-20agent-5f", It.IsAny()))
+ .ReturnsAsync(new[]
+ {
+ new CustomConnectorMetadata { ConnectorInternalId = "shared_weather-20agent-5fabcdef0123456789", ModifiedOn = new DateTime(2024, 6, 1) },
+ });
+
+ var catalog = new Mock();
+ var context = new PowerAppsContext { AccessToken = string.Empty, EnvironmentId = "env" };
+
+ var bindings = new[]
+ {
+ new ConnectionBindingRequest { ConnectionReferenceLogicalName = logicalName, ConnectionId = "conn-7eee" },
+ };
+
+ await synchronizer.ApplyConnectionBindingsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, bindings, CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.EnsureConnectionReferenceExistsAsync(logicalName, resolvedConnectorId, It.IsAny(), It.IsAny()),
+ Times.Once);
+ dataverse.Verify(
+ c => c.BindConnectionReferenceAsync(logicalName, "conn-7eee", It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task ApplyConnectionBindingsAsync_StandardConnectorDeclaredLocallyButMissingInDataverse_EnsuresExistsBeforeBind()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+
+ const string logicalName = "cre98_agentc10conn.shared_office365users.06aae3a7bb9d4d1c82ddd1f7220f754b";
+ const string connectorId = "/providers/Microsoft.PowerApps/apis/shared_office365users";
+
+ // The reference is declared on disk but does not yet exist in Dataverse (e.g. after a reattach where it was
+ // filtered out of provisioning). Binding must create it rather than fail with 'not found in Dataverse'.
+ WriteClassicConnectionReferences(accessor, (logicalName, connectorId));
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ var catalog = new Mock();
+ var context = new PowerAppsContext { AccessToken = string.Empty, EnvironmentId = "env" };
+
+ var bindings = new[]
+ {
+ new ConnectionBindingRequest { ConnectionReferenceLogicalName = logicalName, ConnectionId = "user@contoso.com" },
+ };
+
+ await synchronizer.ApplyConnectionBindingsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, bindings, CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.EnsureConnectionReferenceExistsAsync(logicalName, connectorId, It.IsAny(), It.IsAny()),
+ Times.Once);
+ dataverse.Verify(
+ c => c.BindConnectionReferenceAsync(logicalName, "user@contoso.com", It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_OneConnectorListFails_OtherConnectorsStillListed()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_bogus.x", "/providers/Microsoft.PowerApps/apis/shared_bogus"),
+ ("cree9_agent.shared_teams.y", "/providers/Microsoft.PowerApps/apis/shared_teams"));
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ var catalog = new Mock();
+ catalog
+ .Setup(c => c.ListConnectionsAsync(It.IsAny(), "shared_bogus", It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Connections request failed (404): ApiResourceNotFound"));
+ catalog
+ .Setup(c => c.ListConnectionsAsync(It.IsAny(), "shared_teams", It.IsAny()))
+ .ReturnsAsync(new[] { new ConnectionInstance { Name = "conn-1", DisplayName = "Teams", Status = "Connected" } });
+
+ var context = new PowerAppsContext { AccessToken = "token", EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ Assert.Equal(2, views.Count);
+ var bogus = Assert.Single(views, v => v.ConnectorName == "shared_bogus");
+ Assert.Empty(bogus.Candidates);
+ var teams = Assert.Single(views, v => v.ConnectorName == "shared_teams");
+ Assert.Single(teams.Candidates);
+ }
+
+ private static Mock DataverseWithBoundReference(string logicalName, string connectionId)
+ {
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(new[]
+ {
+ new SyncDataverseClient.ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = logicalName,
+ ConnectionId = connectionId,
+ },
+ });
+ return dataverse;
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_NoCatalogToken_PreservesBoundState()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.x", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var dataverse = DataverseWithBoundReference("cree9_agent.shared_office365.x", "conn-abc");
+ var catalog = new Mock();
+ var context = new PowerAppsContext { AccessToken = string.Empty, EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.Equal("conn-abc", view.BoundConnectionId);
+ Assert.True(view.BoundConnectionExists);
+ catalog.Verify(
+ c => c.ListConnectionsAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_ConnectorListFails_PreservesBoundState()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.x", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var dataverse = DataverseWithBoundReference("cree9_agent.shared_office365.x", "conn-abc");
+ var catalog = new Mock();
+ catalog
+ .Setup(c => c.ListConnectionsAsync(It.IsAny(), "shared_office365", It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Connections request failed (503)"));
+ var context = new PowerAppsContext { AccessToken = "token", EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.True(view.BoundConnectionExists);
+ Assert.Empty(view.Candidates);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_CatalogMissingBoundConnection_MarksUnbound()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.x", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var dataverse = DataverseWithBoundReference("cree9_agent.shared_office365.x", "conn-abc");
+ var catalog = new Mock();
+ catalog
+ .Setup(c => c.ListConnectionsAsync(It.IsAny(), "shared_office365", It.IsAny()))
+ .ReturnsAsync(new[] { new ConnectionInstance { Name = "some-other-conn", Status = "Connected" } });
+ var context = new PowerAppsContext { AccessToken = "token", EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.False(view.BoundConnectionExists);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_CatalogContainsBoundConnection_MarksBound()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.x", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var dataverse = DataverseWithBoundReference("cree9_agent.shared_office365.x", "conn-abc");
+ var catalog = new Mock();
+ catalog
+ .Setup(c => c.ListConnectionsAsync(It.IsAny(), "shared_office365", It.IsAny()))
+ .ReturnsAsync(new[] { new ConnectionInstance { Name = "conn-abc", Status = "Connected" } });
+ var context = new PowerAppsContext { AccessToken = "token", EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.True(view.BoundConnectionExists);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_ConnectorListFails_MarksCatalogUnavailable()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.x", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+ var catalog = new Mock();
+ catalog
+ .Setup(c => c.ListConnectionsAsync(It.IsAny(), "shared_office365", It.IsAny()))
+ .ThrowsAsync(new TimeoutException("Connections request timed out after 30s."));
+ var context = new PowerAppsContext { AccessToken = "token", EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.True(view.CatalogUnavailable);
+ Assert.Empty(view.Candidates);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_ConnectorListSucceedsEmpty_DoesNotMarkCatalogUnavailable()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.x", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+ var catalog = new Mock();
+ catalog
+ .Setup(c => c.ListConnectionsAsync(It.IsAny(), "shared_office365", It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+ var context = new PowerAppsContext { AccessToken = "token", EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.False(view.CatalogUnavailable);
+ Assert.Empty(view.Candidates);
+ }
+
+ [Fact]
+ public async Task GetAgentConnectionViewsAsync_NoCatalogToken_DoesNotMarkCatalogUnavailable()
+ {
+ var (synchronizer, factory, _) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var accessor = (InMemoryFileAccessor)factory.Create(Workspace);
+ WriteClassicConnectionReferences(
+ accessor,
+ ("cree9_agent.shared_office365.x", "/providers/Microsoft.PowerApps/apis/shared_office365"));
+
+ var dataverse = DataverseWithBoundReference("cree9_agent.shared_office365.x", "conn-abc");
+ var catalog = new Mock();
+ var context = new PowerAppsContext { AccessToken = string.Empty, EnvironmentId = "env" };
+
+ var views = await synchronizer.GetAgentConnectionViewsAsync(
+ Workspace, new BotDefinition(), dataverse.Object, catalog.Object, context, CancellationToken.None);
+
+ var view = Assert.Single(views);
+ Assert.False(view.CatalogUnavailable);
+ }
+}
+
diff --git a/src/CopilotStudio.Sync.UnitTests/ConnectionReferenceUsageScannerTests.cs b/src/CopilotStudio.Sync.UnitTests/ConnectionReferenceUsageScannerTests.cs
new file mode 100644
index 0000000..0f30a21
--- /dev/null
+++ b/src/CopilotStudio.Sync.UnitTests/ConnectionReferenceUsageScannerTests.cs
@@ -0,0 +1,222 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using Microsoft.CopilotStudio.McsCore;
+using Xunit;
+
+namespace Microsoft.CopilotStudio.Sync.UnitTests;
+
+public class ConnectionReferenceUsageScannerTests
+{
+ private static InMemoryFileAccessor CreateAccessor()
+ => new InMemoryFileAccessor(new DirectoryPath("c:/test/workspace/"));
+
+ private static void Write(InMemoryFileAccessor accessor, string relativePath, string content)
+ {
+ using var stream = accessor.OpenWrite(new AgentFilePath(relativePath));
+ using var writer = new StreamWriter(stream);
+ writer.Write(content);
+ }
+
+ [Fact]
+ public void Scan_ClassifiesActionAndTopicUsages()
+ {
+ var accessor = CreateAccessor();
+ Write(accessor, "actions/sendmail.mcs.yml", "kind: TaskDialog\nconnectionReference: cr_office365\n");
+ Write(accessor, "topics/greeting.mcs.yml", "kind: AdaptiveDialog\nconnectionReference: cr_office365\n");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ var usages = scan.GetUsages("cr_office365");
+ Assert.Equal(2, usages.Length);
+ Assert.Contains(usages, u => u.Kind == UsageKind.Action && u.FilePath == "actions/sendmail.mcs.yml");
+ Assert.Contains(usages, u => u.Kind == UsageKind.Topic && u.FilePath == "topics/greeting.mcs.yml");
+ Assert.Contains("cr_office365", scan.AuthoredLogicalNames);
+ }
+
+ [Fact]
+ public void Scan_DetectsUsagesInLegacyMcsYamlFiles()
+ {
+ var accessor = CreateAccessor();
+ Write(accessor, "actions/sendmail.mcs.yaml", "kind: TaskDialog\nconnectionReference: cr_office365\n");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ var usage = Assert.Single(scan.GetUsages("cr_office365"));
+ Assert.Equal(UsageKind.Action, usage.Kind);
+ Assert.Equal("actions/sendmail.mcs.yaml", usage.FilePath);
+ Assert.Equal("sendmail", usage.DisplayName);
+ Assert.Contains("cr_office365", scan.AuthoredLogicalNames);
+ }
+
+ [Fact]
+ public void Scan_IgnoresLegacyConnectionReferencesYamlDeclarationFile()
+ {
+ var accessor = CreateAccessor();
+ Write(accessor, "connectionreferences.mcs.yaml", "connectionReference: cr_office365\n");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ Assert.Empty(scan.GetUsages("cr_office365"));
+ Assert.Empty(scan.AuthoredLogicalNames);
+ }
+
+ [Theory]
+ [InlineData("kind: TaskDialog\nconnectionReference: 'cr_office365'\n")]
+ [InlineData("kind: TaskDialog\nconnectionReference: \"cr_office365\"\n")]
+ public void Scan_DetectsQuotedConnectionReferenceValues(string content)
+ {
+ var accessor = CreateAccessor();
+ Write(accessor, "actions/sendmail.mcs.yml", content);
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ var usage = Assert.Single(scan.GetUsages("cr_office365"));
+ Assert.Equal(UsageKind.Action, usage.Kind);
+ Assert.Equal("actions/sendmail.mcs.yml", usage.FilePath);
+ Assert.Contains("cr_office365", scan.AuthoredLogicalNames);
+ }
+
+ [Fact]
+ public void Scan_IgnoresConnectionReferencesDeclarationFile()
+ {
+ var accessor = CreateAccessor();
+ Write(accessor, "connectionreferences.mcs.yml", "connectionReference: cr_office365\n");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ Assert.Empty(scan.GetUsages("cr_office365"));
+ Assert.Empty(scan.AuthoredLogicalNames);
+ }
+
+ [Fact]
+ public void Scan_ReadsWorkflowMetadataStateAndReferences()
+ {
+ var accessor = CreateAccessor();
+ Write(
+ accessor,
+ "workflows/notify/metadata.yml",
+ "name: Notify Flow\nworkflowId: 11111111-1111-1111-1111-111111111111\nstateCode: 1\nstatusCode: 2\nconnectionReferences:\n - cr_office365\n - cr_sharepoint\n");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ var workflow = Assert.Single(scan.Workflows);
+ Assert.Equal("Notify Flow", workflow.DisplayName);
+ Assert.Equal("11111111-1111-1111-1111-111111111111", workflow.WorkflowId);
+ Assert.Equal(WorkflowState.Activated, workflow.State);
+ Assert.Equal(new[] { "cr_office365", "cr_sharepoint" }, workflow.ConnectionReferenceLogicalNames);
+
+ var usages = scan.GetUsages("cr_office365");
+ var usage = Assert.Single(usages);
+ Assert.Equal(UsageKind.Workflow, usage.Kind);
+ Assert.Equal("workflows/notify/metadata.yml", usage.FilePath);
+ Assert.Equal("Notify Flow", usage.DisplayName);
+ }
+
+ [Fact]
+ public void Scan_DraftWorkflowMapsToDraftState()
+ {
+ var accessor = CreateAccessor();
+ Write(
+ accessor,
+ "workflows/draftflow/metadata.yml",
+ "name: Draft Flow\nstateCode: 0\nstatusCode: 1\nconnectionReferences: []\n");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ var workflow = Assert.Single(scan.Workflows);
+ Assert.Equal(WorkflowState.Draft, workflow.State);
+ Assert.Empty(workflow.ConnectionReferenceLogicalNames);
+ }
+
+ [Fact]
+ public void Scan_MetadataMissingConnectionReferences_FallsBackToWorkflowJson()
+ {
+ var accessor = CreateAccessor();
+ Write(
+ accessor,
+ "workflows/notify/metadata.yml",
+ "name: Notify Flow\nworkflowId: 44444444-4444-4444-4444-444444444444\nstateCode: 0\nstatusCode: 1\n");
+ Write(
+ accessor,
+ "workflows/notify/workflow.json",
+ "{\"properties\":{\"connectionReferences\":{\"shared_office365\":{\"connection\":{\"connectionReferenceLogicalName\":\"cr_office365\"}}}}}");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ var workflow = Assert.Single(scan.Workflows);
+ Assert.Equal(new[] { "cr_office365" }, workflow.ConnectionReferenceLogicalNames);
+
+ var usage = Assert.Single(scan.GetUsages("cr_office365"));
+ Assert.Equal(UsageKind.Workflow, usage.Kind);
+ Assert.Equal("workflows/notify/metadata.yml", usage.FilePath);
+ }
+
+ [Fact]
+ public void Scan_MetadataHasConnectionReferences_DoesNotReadWorkflowJson()
+ {
+ var accessor = CreateAccessor();
+ Write(
+ accessor,
+ "workflows/notify/metadata.yml",
+ "name: Notify Flow\nstateCode: 0\nstatusCode: 1\nconnectionReferences:\n - cr_frommetadata\n");
+ Write(
+ accessor,
+ "workflows/notify/workflow.json",
+ "{\"properties\":{\"connectionReferences\":{\"shared_office365\":{\"connection\":{\"connectionReferenceLogicalName\":\"cr_fromjson\"}}}}}");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ connectorInternalIdByLogicalName: System.Collections.Immutable.ImmutableDictionary.Empty,
+ System.Threading.CancellationToken.None);
+
+ var workflow = Assert.Single(scan.Workflows);
+ Assert.Equal(new[] { "cr_frommetadata" }, workflow.ConnectionReferenceLogicalNames);
+ }
+
+ [Fact]
+ public void Scan_MatchesCustomConnectorByInternalId()
+ {
+ var accessor = CreateAccessor();
+ Write(
+ accessor,
+ "connectors/weather/metadata.yml",
+ "{ \"connectorinternalid\": \"shared_weather-123\", \"displayname\": \"Weather\" }");
+ var map = System.Collections.Immutable.ImmutableDictionary.Empty
+ .Add("cr_weather", "shared_weather-123");
+
+ var scan = new ConnectionReferenceUsageScanner().Scan(
+ accessor,
+ map,
+ System.Threading.CancellationToken.None);
+
+ var usages = scan.GetUsages("cr_weather");
+ var usage = Assert.Single(usages);
+ Assert.Equal(UsageKind.Connector, usage.Kind);
+ Assert.Equal("connectors/weather/metadata.yml", usage.FilePath);
+ Assert.Equal("Weather", usage.DisplayName);
+ }
+}
diff --git a/src/CopilotStudio.Sync.UnitTests/CustomConnectorPushTests.cs b/src/CopilotStudio.Sync.UnitTests/CustomConnectorPushTests.cs
new file mode 100644
index 0000000..30ac526
--- /dev/null
+++ b/src/CopilotStudio.Sync.UnitTests/CustomConnectorPushTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CopilotStudio.McsCore;
+using Microsoft.CopilotStudio.Sync.Dataverse;
+using Moq;
+using Xunit;
+
+namespace Microsoft.CopilotStudio.Sync.UnitTests;
+
+public class CustomConnectorPushTests : IDisposable
+{
+ private readonly string _root;
+ private readonly DirectoryPath _workspace;
+ private readonly Guid _connectorId = Guid.NewGuid();
+ private readonly string _connectorFolder;
+
+ public CustomConnectorPushTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "mcs-connector-perf-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ _workspace = new DirectoryPath(_root.Replace('\\', '/') + "/");
+ _connectorFolder = Path.Combine(_root, "connectors", "MyConnector-" + _connectorId.ToString("D"));
+ Directory.CreateDirectory(_connectorFolder);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+
+ private static WorkspaceSynchronizer CreateSynchronizer()
+ {
+ var fileParser = new SyncMcsFileParser(LspProjectorService.Instance);
+ var fileAccessorFactory = new FileAccessorFactory();
+ var island = new Mock();
+ var progress = new TestSyncProgress(new List());
+ var pathResolver = new LspComponentPathResolver();
+
+ return new WorkspaceSynchronizer(fileParser, fileAccessorFactory, island.Object, progress, pathResolver);
+ }
+
+ private void WriteConnectorFiles(string openApiJson)
+ {
+ var metadata =
+ "{" +
+ $"\"connectorid\":\"{_connectorId}\"," +
+ "\"name\":\"MyConnector\"," +
+ "\"displayname\":\"My Connector\"," +
+ "\"connectorinternalid\":\"shared_myconnector-5f1234567890abcdef\"," +
+ "\"openapidefinition\":\"connectors/MyConnector-" + _connectorId.ToString("D") + "/openapidefinition.json\"," +
+ "\"connectortype\":0" +
+ "}";
+
+ File.WriteAllText(Path.Combine(_connectorFolder, "metadata.yml"), metadata);
+ File.WriteAllText(Path.Combine(_connectorFolder, "openapidefinition.json"), openApiJson);
+ }
+
+ [Fact]
+ public async Task PushCustomConnectorsAsync_UnchangedSinceLastPush_SkipsUpsert()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteConnectorFiles("{\n \"swagger\": \"2.0\"\n}");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpsertConnectorAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ await synchronizer.PushCustomConnectorsAsync(_workspace, dataverse.Object, CancellationToken.None);
+ await synchronizer.PushCustomConnectorsAsync(_workspace, dataverse.Object, CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.UpsertConnectorAsync(It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task PushCustomConnectorsAsync_ContentChanged_ReUpserts()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteConnectorFiles("{\n \"swagger\": \"2.0\"\n}");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpsertConnectorAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ await synchronizer.PushCustomConnectorsAsync(_workspace, dataverse.Object, CancellationToken.None);
+
+ WriteConnectorFiles("{\n \"swagger\": \"2.0\",\n \"host\": \"example.com\"\n}");
+ await synchronizer.PushCustomConnectorsAsync(_workspace, dataverse.Object, CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.UpsertConnectorAsync(It.IsAny(), It.IsAny()),
+ Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task PushCustomConnectorsAsync_WhitespaceOnlyJsonChange_SkipsUpsert()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteConnectorFiles("{\"swagger\":\"2.0\",\"host\":\"example.com\"}");
+
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpsertConnectorAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ await synchronizer.PushCustomConnectorsAsync(_workspace, dataverse.Object, CancellationToken.None);
+
+ WriteConnectorFiles("{\n \"swagger\": \"2.0\",\n \"host\": \"example.com\"\n}");
+ await synchronizer.PushCustomConnectorsAsync(_workspace, dataverse.Object, CancellationToken.None);
+
+ dataverse.Verify(
+ c => c.UpsertConnectorAsync(It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+}
diff --git a/src/CopilotStudio.Sync.UnitTests/KnowledgeFileUnifyTests.cs b/src/CopilotStudio.Sync.UnitTests/KnowledgeFileUnifyTests.cs
index 50c2795..f04d3ba 100644
--- a/src/CopilotStudio.Sync.UnitTests/KnowledgeFileUnifyTests.cs
+++ b/src/CopilotStudio.Sync.UnitTests/KnowledgeFileUnifyTests.cs
@@ -196,6 +196,55 @@ public async Task UploadKnowledgeFiles_SkipsFileMissingFromDisk()
Times.Never);
}
+ [Fact]
+ public async Task UploadKnowledgeFiles_UnchangedSinceLastUpload_SkipsSecondUpload()
+ {
+ var (_, definition, accessor, synchronizer, workspace) =
+ await CliAgentRoundTripReadTests.PushFixtureAsClone("FoodLogger");
+
+ var fileComponent = definition.Components.OfType().Single();
+ await accessor.WriteAsync(
+ new AgentFilePath($"capabilities/knowledge/files/{fileComponent.DisplayName}"), FileBytes, CancellationToken.None);
+
+ var dataverse = new Mock();
+ dataverse.Setup(d => d.UploadKnowledgeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ var first = await synchronizer.UploadKnowledgeFilesAsync(workspace, dataverse.Object, CancellationToken.None);
+ var second = await synchronizer.UploadKnowledgeFilesAsync(workspace, dataverse.Object, CancellationToken.None);
+
+ Assert.Single(first);
+ Assert.Empty(second);
+ dataverse.Verify(
+ d => d.UploadKnowledgeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task UploadKnowledgeFiles_ContentChanged_ReUploads()
+ {
+ var (_, definition, accessor, synchronizer, workspace) =
+ await CliAgentRoundTripReadTests.PushFixtureAsClone("FoodLogger");
+
+ var fileComponent = definition.Components.OfType().Single();
+ var contentPath = new AgentFilePath($"capabilities/knowledge/files/{fileComponent.DisplayName}");
+ await accessor.WriteAsync(contentPath, FileBytes, CancellationToken.None);
+
+ var dataverse = new Mock();
+ dataverse.Setup(d => d.UploadKnowledgeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ await synchronizer.UploadKnowledgeFilesAsync(workspace, dataverse.Object, CancellationToken.None);
+
+ await accessor.WriteAsync(contentPath, Encoding.UTF8.GetBytes("col1,col2\n3,4\n9,9\n"), CancellationToken.None);
+ var second = await synchronizer.UploadKnowledgeFilesAsync(workspace, dataverse.Object, CancellationToken.None);
+
+ Assert.Single(second);
+ dataverse.Verify(
+ d => d.UploadKnowledgeFileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Exactly(2));
+ }
+
[Fact]
public async Task GetLocalChanges_NewKnowledgeFileOnDisk_CreatesComponentWithoutWritingMetadata()
{
diff --git a/src/CopilotStudio.Sync.UnitTests/PowerAppsClientTests.cs b/src/CopilotStudio.Sync.UnitTests/PowerAppsClientTests.cs
new file mode 100644
index 0000000..4f5cf62
--- /dev/null
+++ b/src/CopilotStudio.Sync.UnitTests/PowerAppsClientTests.cs
@@ -0,0 +1,111 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CopilotStudio.Sync.Dataverse;
+using Xunit;
+
+namespace Microsoft.CopilotStudio.Sync.UnitTests;
+
+public class PowerAppsClientTests
+{
+ private static PowerAppsContext Context => new()
+ {
+ AccessToken = "test-token",
+ EnvironmentId = "test-env",
+ };
+
+ [Fact]
+ public async Task ListConnectionsAsync_ReturnsConnections_OnSuccess()
+ {
+ const string json = "{\"value\":[{\"name\":\"conn1\",\"properties\":{\"displayName\":\"Conn 1\",\"statuses\":[{\"status\":\"Connected\"}]}}]}";
+ var handler = new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json"),
+ }));
+ var client = new PowerAppsClient(new HttpClient(handler));
+
+ var result = await client.ListConnectionsAsync(Context, "shared_office365users", CancellationToken.None);
+
+ var connection = Assert.Single(result);
+ Assert.Equal("conn1", connection.Name);
+ Assert.Equal("Conn 1", connection.DisplayName);
+ Assert.Equal("Connected", connection.Status);
+ }
+
+ [Fact]
+ public async Task ListConnectionsAsync_FollowsNextLinkAcrossPages()
+ {
+ const string page1 = "{\"value\":[{\"name\":\"conn1\",\"properties\":{\"displayName\":\"Conn 1\"}}],\"@odata.nextLink\":\"https://api.powerapps.com/page2\"}";
+ const string page2 = "{\"value\":[{\"name\":\"conn2\",\"properties\":{\"displayName\":\"Conn 2\"}}]}";
+ var requestUris = new List();
+ var handler = new StubHandler((request, _) =>
+ {
+ var uri = request.RequestUri!.ToString();
+ requestUris.Add(uri);
+ var body = uri.Contains("page2", StringComparison.OrdinalIgnoreCase) ? page2 : page1;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ });
+ });
+ var client = new PowerAppsClient(new HttpClient(handler));
+
+ var result = await client.ListConnectionsAsync(Context, "shared_office365users", CancellationToken.None);
+
+ Assert.Equal(new[] { "conn1", "conn2" }, result.Select(c => c.Name).ToArray());
+ Assert.Equal(2, requestUris.Count);
+ Assert.Contains(requestUris, u => u.Contains("page2", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task ListConnectionsAsync_RequestExceedsTimeout_ThrowsTimeoutException()
+ {
+ var handler = new StubHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+ var client = new PowerAppsClient(new HttpClient(handler), requestTimeout: TimeSpan.FromMilliseconds(100));
+
+ await Assert.ThrowsAsync(
+ () => client.ListConnectionsAsync(Context, "shared_office365users", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ListConnectionsAsync_CallerCancels_ThrowsOperationCanceledNotTimeout()
+ {
+ var handler = new StubHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+ var client = new PowerAppsClient(new HttpClient(handler), requestTimeout: TimeSpan.FromSeconds(30));
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ () => client.ListConnectionsAsync(Context, "shared_office365users", cts.Token));
+ }
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly Func> _handler;
+
+ public StubHandler(Func> handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return _handler(request, cancellationToken);
+ }
+ }
+}
diff --git a/src/CopilotStudio.Sync.UnitTests/PullConnectionReferencePruneTests.cs b/src/CopilotStudio.Sync.UnitTests/PullConnectionReferencePruneTests.cs
new file mode 100644
index 0000000..5f76332
--- /dev/null
+++ b/src/CopilotStudio.Sync.UnitTests/PullConnectionReferencePruneTests.cs
@@ -0,0 +1,248 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using Microsoft.Agents.ObjectModel;
+using Microsoft.Agents.ObjectModel.Yaml;
+using Microsoft.Agents.Platform.Content;
+using Microsoft.CopilotStudio.McsCore;
+using Microsoft.CopilotStudio.Sync.Dataverse;
+using Moq;
+using System.Text.Json;
+using Xunit;
+using static Microsoft.CopilotStudio.Sync.Dataverse.SyncDataverseClient;
+
+namespace Microsoft.CopilotStudio.Sync.UnitTests;
+
+public class PullConnectionReferencePruneTests
+{
+ private const string KeepRef = "cre98_AgentKeep.shared_office365users.aaaa";
+ private const string GoneRef = "cre98_AgentGone.shared_sharepointonline.bbbb";
+
+ [Fact]
+ public async Task Pull_WhenWorkflowDeletedInCloud_RemovesOrphanedConnectionReference()
+ {
+ var keepId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+ var goneId = Guid.Parse("55555555-5555-5555-5555-555555555555");
+
+ await RunPullScenarioAsync(
+ cloneWorkflows: new[]
+ {
+ MakeWorkflow(keepId, "Keep Flow", KeepRef),
+ MakeWorkflow(goneId, "Gone Flow", GoneRef),
+ },
+ pullWorkflows: new[] { MakeWorkflow(keepId, "Keep Flow", KeepRef) },
+ injectOrphanIntoCacheAfterClone: false,
+ assert: (fileAccessor) =>
+ {
+ var logicalNames = ReadDefinition(fileAccessor).ConnectionReferences
+ .Select(c => c.ConnectionReferenceLogicalName.Value).ToList();
+ Assert.Contains(KeepRef, logicalNames);
+ Assert.DoesNotContain(GoneRef, logicalNames);
+
+ var referencesFile = ReadText(fileAccessor, "connectionreferences.mcs.yml");
+ Assert.Contains(KeepRef, referencesFile);
+ Assert.DoesNotContain(GoneRef, referencesFile);
+ });
+ }
+
+ [Fact]
+ public async Task Pull_WithoutDeletions_KeepsExistingReferences()
+ {
+ var keepId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ await RunPullScenarioAsync(
+ cloneWorkflows: new[] { MakeWorkflow(keepId, "Keep Flow", KeepRef) },
+ pullWorkflows: new[] { MakeWorkflow(keepId, "Keep Flow", KeepRef) },
+ injectOrphanIntoCacheAfterClone: true,
+ assert: (fileAccessor) =>
+ {
+ var logicalNames = ReadDefinition(fileAccessor).ConnectionReferences
+ .Select(c => c.ConnectionReferenceLogicalName.Value).ToList();
+ Assert.Contains(KeepRef, logicalNames);
+ Assert.Contains(GoneRef, logicalNames);
+ });
+ }
+
+ [Fact]
+ public async Task Pull_WhenWorkflowDownloadFails_DoesNotPruneOrWipeFlows()
+ {
+ var keepId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ await RunPullScenarioAsync(
+ cloneWorkflows: new[] { MakeWorkflow(keepId, "Keep Flow", KeepRef) },
+ pullWorkflows: new[] { MakeWorkflow(keepId, "Keep Flow", KeepRef) },
+ injectOrphanIntoCacheAfterClone: false,
+ failFirstPullWorkflowDownload: true,
+ assert: (fileAccessor) =>
+ {
+ var definition = ReadDefinition(fileAccessor);
+ var logicalNames = definition.ConnectionReferences
+ .Select(c => c.ConnectionReferenceLogicalName.Value).ToList();
+ Assert.Contains(KeepRef, logicalNames);
+ Assert.Contains(keepId, definition.Flows.Select(f => f.WorkflowId.Value));
+ });
+ }
+
+ private static async Task RunPullScenarioAsync(
+ WorkflowMetadata[] cloneWorkflows,
+ WorkflowMetadata[] pullWorkflows,
+ bool injectOrphanIntoCacheAfterClone,
+ Action assert,
+ WorkflowMetadata[]? secondPullWorkflows = null,
+ bool injectOrphanBeforeSecondPull = false,
+ bool failFirstPullWorkflowDownload = false)
+ {
+ var (synchronizer, fileAccessorFactory, mockIsland) = ComponentWriterDefensiveTests.CreateSyncInfrastructure();
+ var workspaceRoot = Path.Combine(Path.GetTempPath(), "pullprune-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(workspaceRoot);
+ var workspace = new DirectoryPath(workspaceRoot.Replace('\\', '/') + "/");
+ var agentId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+
+ try
+ {
+ var currentWorkflows = cloneWorkflows;
+ var failWorkflowDownload = false;
+ var mockDataverse = new Mock();
+ mockDataverse
+ .Setup(x => x.DownloadAllWorkflowsForAgentAsync(It.IsAny(), It.IsAny()))
+ .Returns(() =>
+ {
+ if (failWorkflowDownload)
+ {
+ failWorkflowDownload = false;
+ return Task.FromException(new IOException("simulated workflow download failure"));
+ }
+
+ return Task.FromResult(currentWorkflows);
+ });
+ mockDataverse
+ .Setup(x => x.DownloadAllAIPromptsForAgentAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+ mockDataverse
+ .Setup(x => x.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync((IEnumerable names, CancellationToken _) => names
+ .Select(n => new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = n,
+ ConnectionId = string.Empty,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/" + ConnectorOf(n),
+ })
+ .ToArray());
+
+ var opContext = ComponentWriterDefensiveTests.CreateMockOperationContext();
+ var syncInfo = new AgentSyncInfo { AgentId = agentId };
+
+ var bot = new BotEntity.Builder
+ {
+ SchemaName = new BotEntitySchemaName("cr123"),
+ CdsBotId = agentId,
+ }.Build();
+
+ mockIsland
+ .Setup(x => x.GetComponentsAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PvaComponentChangeSet(null, bot, "token-1"));
+
+ await synchronizer.CloneChangesAsync(workspace, new ReferenceTracker(), opContext, mockDataverse.Object, syncInfo, CancellationToken.None);
+
+ var fileAccessor = (InMemoryFileAccessor)fileAccessorFactory.Create(workspace);
+
+ if (injectOrphanIntoCacheAfterClone)
+ {
+ InjectOrphanIntoCache(fileAccessor);
+ }
+
+ var previousDefinition = ReadDefinition(fileAccessor);
+
+ currentWorkflows = pullWorkflows;
+ failWorkflowDownload = failFirstPullWorkflowDownload;
+ mockIsland
+ .Setup(x => x.GetComponentsAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PvaComponentChangeSet(null, bot, "token-2"));
+
+ await synchronizer.PullExistingChangesAsync(workspace, opContext, previousDefinition, mockDataverse.Object, syncInfo, CancellationToken.None);
+
+ if (secondPullWorkflows != null)
+ {
+ failWorkflowDownload = false;
+
+ if (injectOrphanBeforeSecondPull)
+ {
+ InjectOrphanIntoCache(fileAccessor);
+ }
+
+ var previousDefinition2 = ReadDefinition(fileAccessor);
+ currentWorkflows = secondPullWorkflows;
+ mockIsland
+ .Setup(x => x.GetComponentsAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PvaComponentChangeSet(null, bot, "token-3"));
+
+ await synchronizer.PullExistingChangesAsync(workspace, opContext, previousDefinition2, mockDataverse.Object, syncInfo, CancellationToken.None);
+ }
+
+ assert(fileAccessor);
+ }
+ finally
+ {
+ if (Directory.Exists(workspaceRoot))
+ {
+ Directory.Delete(workspaceRoot, true);
+ }
+ }
+ }
+
+ private static void InjectOrphanIntoCache(InMemoryFileAccessor fileAccessor)
+ {
+ var current = ReadDefinition(fileAccessor);
+ if (current.ConnectionReferences.Any(c => string.Equals(c.ConnectionReferenceLogicalName.Value, GoneRef, StringComparison.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
+ var withOrphan = current.WithConnectionReferences(current.ConnectionReferences.Add(
+ new ConnectionReference.Builder
+ {
+ ConnectionReferenceLogicalName = GoneRef,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_sharepointonline",
+ }.Build()));
+ WorkspaceSynchronizer.WriteCloudCache(fileAccessor, withOrphan);
+ }
+
+ private static WorkflowMetadata MakeWorkflow(Guid workflowId, string name, string logicalName) => new()
+ {
+ WorkflowId = workflowId,
+ Name = name,
+ ClientData = WorkflowJsonReferencing(logicalName),
+ };
+
+ private static string ConnectorOf(string logicalName)
+ {
+ var parts = logicalName.Split('.');
+ return parts.FirstOrDefault(p => p.StartsWith("shared_", StringComparison.OrdinalIgnoreCase)) ?? "shared_unknown";
+ }
+
+ private static string WorkflowJsonReferencing(string logicalName) =>
+ "{\n" +
+ " \"properties\": {\n" +
+ " \"connectionReferences\": {\n" +
+ " \"shared_x\": {\n" +
+ $" \"connection\": {{ \"connectionReferenceLogicalName\": \"{logicalName}\" }}\n" +
+ " }\n" +
+ " }\n" +
+ " }\n" +
+ "}";
+
+ private static string ReadText(InMemoryFileAccessor fileAccessor, string path)
+ {
+ using var stream = fileAccessor.OpenRead(new AgentFilePath(path));
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ private static DefinitionBase ReadDefinition(InMemoryFileAccessor fileAccessor)
+ {
+ using var stream = fileAccessor.OpenRead(new AgentFilePath(".mcs/botdefinition.json"));
+ using (YamlSerializationContext.UseYamlPassThroughSerializationContext())
+ {
+ return JsonSerializer.Deserialize(stream, ElementSerializer.CreateOptions())!;
+ }
+ }
+}
diff --git a/src/CopilotStudio.Sync.UnitTests/WorkflowDraftPushTests.cs b/src/CopilotStudio.Sync.UnitTests/WorkflowDraftPushTests.cs
new file mode 100644
index 0000000..a6aa473
--- /dev/null
+++ b/src/CopilotStudio.Sync.UnitTests/WorkflowDraftPushTests.cs
@@ -0,0 +1,284 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using Microsoft.Agents.ObjectModel;
+using Microsoft.CopilotStudio.McsCore;
+using Microsoft.CopilotStudio.Sync.Dataverse;
+using Moq;
+using Xunit;
+using static Microsoft.CopilotStudio.Sync.Dataverse.SyncDataverseClient;
+
+namespace Microsoft.CopilotStudio.Sync.UnitTests;
+
+public class WorkflowDraftPushTests : IDisposable
+{
+ private const string RefLogicalName = "cr_testref";
+
+ private readonly string _root;
+ private readonly DirectoryPath _workspace;
+ private readonly Guid _workflowId = Guid.NewGuid();
+ private readonly string _workflowFolder;
+
+ public WorkflowDraftPushTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "mcs-workflow-draft-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ _workspace = new DirectoryPath(_root.Replace('\\', '/') + "/");
+ _workflowFolder = Path.Combine(_root, "workflows", "MyFlow-" + _workflowId.ToString("D"));
+ Directory.CreateDirectory(_workflowFolder);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ catch (IOException)
+ {
+ }
+ }
+
+ private static WorkspaceSynchronizer CreateSynchronizer()
+ {
+ var fileParser = new SyncMcsFileParser(LspProjectorService.Instance);
+ var fileAccessorFactory = new FileAccessorFactory();
+ var island = new Mock();
+ var progress = new TestSyncProgress(new List());
+ var pathResolver = new LspComponentPathResolver();
+
+ return new WorkspaceSynchronizer(fileParser, fileAccessorFactory, island.Object, progress, pathResolver);
+ }
+
+ private void WriteWorkflowFiles(string workflowJson, int stateCode = 1, int statusCode = 2)
+ {
+ var metadata =
+ $"workflowId: {_workflowId}\n" +
+ "name: My Flow\n" +
+ "type: 1\n" +
+ "category: 5\n" +
+ $"stateCode: {stateCode}\n" +
+ $"statusCode: {statusCode}\n";
+
+ File.WriteAllText(Path.Combine(_workflowFolder, "metadata.yml"), metadata);
+ File.WriteAllText(Path.Combine(_workflowFolder, "workflow.json"), workflowJson);
+ }
+
+ private static string WorkflowJsonWithReference() =>
+ "{\n" +
+ " \"properties\": {\n" +
+ " \"connectionReferences\": {\n" +
+ " \"shared_x\": {\n" +
+ $" \"connection\": {{ \"connectionReferenceLogicalName\": \"{RefLogicalName}\" }}\n" +
+ " }\n" +
+ " }\n" +
+ " }\n" +
+ "}";
+
+ private static Mock CreateDataverse(WorkflowMetadata?[] captured, ConnectionReferenceInfo[]? references = null)
+ {
+ var dataverse = new Mock();
+ dataverse
+ .Setup(c => c.UpdateWorkflowAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((_, m, _) => captured[0] = m)
+ .ReturnsAsync(new WorkflowResponse());
+
+ dataverse
+ .Setup(c => c.GetConnectionReferencesByLogicalNamesAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync(references ?? Array.Empty());
+
+ return dataverse;
+ }
+
+ [Fact]
+ public async Task ReattachWithUnboundConnection_UploadsAsDraft()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles(WorkflowJsonWithReference());
+
+ var captured = new WorkflowMetadata?[1];
+ var dataverse = CreateDataverse(captured, new[]
+ {
+ new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = RefLogicalName,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_x",
+ ConnectionId = string.Empty,
+ },
+ });
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.ActivateWhenConnectionsBound);
+
+ Assert.NotNull(captured[0]);
+ Assert.Equal(0, captured[0]!.StateCode);
+ Assert.Equal(1, captured[0]!.StatusCode);
+ }
+
+ [Fact]
+ public async Task ReattachWithBoundConnection_Activates()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles(WorkflowJsonWithReference());
+
+ var captured = new WorkflowMetadata?[1];
+ var dataverse = CreateDataverse(captured, new[]
+ {
+ new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = RefLogicalName,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_x",
+ ConnectionId = "shared-x-connection",
+ },
+ });
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.ActivateWhenConnectionsBound);
+
+ Assert.NotNull(captured[0]);
+ Assert.Equal(1, captured[0]!.StateCode);
+ Assert.Equal(2, captured[0]!.StatusCode);
+ }
+
+ [Fact]
+ public async Task ReattachWithoutConnectionReferences_Activates()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles("{\n \"properties\": {}\n}");
+
+ var captured = new WorkflowMetadata?[1];
+ var dataverse = CreateDataverse(captured);
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.ActivateWhenConnectionsBound);
+
+ Assert.NotNull(captured[0]);
+ Assert.Equal(1, captured[0]!.StateCode);
+ Assert.Equal(2, captured[0]!.StatusCode);
+ }
+
+ [Fact]
+ public async Task PushPreservesSavedActivatedState()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles("{\n \"properties\": {}\n}");
+
+ var captured = new WorkflowMetadata?[1];
+ var dataverse = CreateDataverse(captured);
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None);
+
+ Assert.NotNull(captured[0]);
+ Assert.Equal(1, captured[0]!.StateCode);
+ Assert.Equal(2, captured[0]!.StatusCode);
+ }
+
+ [Fact]
+ public async Task PushWithUnboundConnection_DowngradesToDraft()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles(WorkflowJsonWithReference());
+
+ var captured = new WorkflowMetadata?[1];
+ var dataverse = CreateDataverse(captured, new[]
+ {
+ new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = RefLogicalName,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_x",
+ ConnectionId = string.Empty,
+ },
+ });
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.DraftWhenConnectionsUnbound);
+
+ Assert.NotNull(captured[0]);
+ Assert.Equal(0, captured[0]!.StateCode);
+ Assert.Equal(1, captured[0]!.StatusCode);
+ }
+
+ [Fact]
+ public async Task PushWithBoundConnection_PreservesActivated()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles(WorkflowJsonWithReference());
+
+ var captured = new WorkflowMetadata?[1];
+ var dataverse = CreateDataverse(captured, new[]
+ {
+ new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = RefLogicalName,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_x",
+ ConnectionId = "shared-x-connection",
+ },
+ });
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.DraftWhenConnectionsUnbound);
+
+ Assert.NotNull(captured[0]);
+ Assert.Equal(1, captured[0]!.StateCode);
+ Assert.Equal(2, captured[0]!.StatusCode);
+ }
+
+ [Fact]
+ public async Task PushWithSavedDraftAndBoundConnection_StaysDraft()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles(WorkflowJsonWithReference(), stateCode: 0, statusCode: 1);
+
+ var captured = new WorkflowMetadata?[1];
+ var dataverse = CreateDataverse(captured, new[]
+ {
+ new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = RefLogicalName,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_x",
+ ConnectionId = "shared-x-connection",
+ },
+ });
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, dataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.DraftWhenConnectionsUnbound);
+
+ Assert.NotNull(captured[0]);
+ Assert.Equal(0, captured[0]!.StateCode);
+ Assert.Equal(1, captured[0]!.StatusCode);
+ }
+
+ [Fact]
+ public async Task PushUnchangedActivatedWorkflow_ConnectionBecameUnbound_DowngradesToDraft()
+ {
+ var synchronizer = CreateSynchronizer();
+ WriteWorkflowFiles(WorkflowJsonWithReference());
+
+ var capturedBound = new WorkflowMetadata?[1];
+ var boundDataverse = CreateDataverse(capturedBound, new[]
+ {
+ new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = RefLogicalName,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_x",
+ ConnectionId = "shared-x-connection",
+ },
+ });
+
+ var (_, cloudFlowMetadata) = await synchronizer.UpsertWorkflowForAgentAsync(_workspace, boundDataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.DraftWhenConnectionsUnbound);
+ Assert.Equal(1, capturedBound[0]!.StateCode);
+
+ var fileAccessor = new FileAccessorFactory().Create(_workspace);
+ WorkspaceSynchronizer.WriteCloudCache(fileAccessor, new BotDefinition().WithFlows(cloudFlowMetadata.Workflows));
+
+ var capturedUnbound = new WorkflowMetadata?[1];
+ var unboundDataverse = CreateDataverse(capturedUnbound, new[]
+ {
+ new ConnectionReferenceInfo
+ {
+ ConnectionReferenceLogicalName = RefLogicalName,
+ ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_x",
+ ConnectionId = string.Empty,
+ },
+ });
+
+ await synchronizer.UpsertWorkflowForAgentAsync(_workspace, unboundDataverse.Object, Guid.NewGuid(), CancellationToken.None, WorkflowActivationMode.DraftWhenConnectionsUnbound);
+
+ Assert.NotNull(capturedUnbound[0]);
+ Assert.Equal(0, capturedUnbound[0]!.StateCode);
+ Assert.Equal(1, capturedUnbound[0]!.StatusCode);
+ }
+}
diff --git a/src/CopilotStudio.Sync/ConnectionReferenceUsageScanner.cs b/src/CopilotStudio.Sync/ConnectionReferenceUsageScanner.cs
new file mode 100644
index 0000000..7c33a54
--- /dev/null
+++ b/src/CopilotStudio.Sync/ConnectionReferenceUsageScanner.cs
@@ -0,0 +1,431 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using Microsoft.CopilotStudio.McsCore;
+using Microsoft.CopilotStudio.Sync.Dataverse;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace Microsoft.CopilotStudio.Sync;
+
+internal static class ConnectionReferenceText
+{
+ private static readonly Regex ConnectionReferenceLine = new Regex(@"^[ \t]*connectionReference:[ \t]*(?:'(?[^']*)'|""(?[^""]*)""|(?[^\s#'""]+))", RegexOptions.Compiled | RegexOptions.Multiline);
+
+ public static IEnumerable ExtractConnectionReferenceNames(string? yamlText)
+ {
+ if (string.IsNullOrEmpty(yamlText) || yamlText!.IndexOf("connectionReference:", StringComparison.OrdinalIgnoreCase) < 0)
+ {
+ yield break;
+ }
+
+ foreach (Match match in ConnectionReferenceLine.Matches(yamlText))
+ {
+ var value = match.Groups["value"].Value;
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ yield return value;
+ }
+ }
+ }
+}
+
+public sealed class ConnectionReferenceUsageScanner
+{
+ private const string ComponentExtension = ".mcs.yml";
+ private const string ComponentExtensionLong = ".mcs.yaml";
+ private const string ConnectionReferencesFileName = "connectionreferences.mcs.yml";
+ private const string ConnectionReferencesFileNameLong = "connectionreferences.mcs.yaml";
+ private const string WorkflowsFolder = "workflows";
+ private const string ConnectorsFolder = "connectors";
+ private const string HiddenFolder = ".mcs";
+
+ ///
+ /// Scans the workspace for connection reference usages.
+ ///
+ /// Accessor for the agent workspace files.
+ /// Maps each declared connection reference logical name to its connector internal id.
+ /// Cancellation token.
+ /// The scan result.
+ public ConnectionReferenceUsageScan Scan(IFileAccessor fileAccessor, IReadOnlyDictionary connectorInternalIdByLogicalName, CancellationToken cancellationToken)
+ {
+ if (fileAccessor == null)
+ {
+ throw new ArgumentNullException(nameof(fileAccessor));
+ }
+
+ connectorInternalIdByLogicalName ??= ImmutableDictionary.Empty;
+ var usages = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ var authored = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var workflows = new List();
+
+ var allFiles = fileAccessor.ListFiles().ToList();
+
+ ScanComponents(fileAccessor, allFiles, usages, authored, cancellationToken);
+ ScanWorkflows(fileAccessor, allFiles, usages, workflows, cancellationToken);
+ ScanConnectors(fileAccessor, allFiles, connectorInternalIdByLogicalName, usages, cancellationToken);
+
+ return new ConnectionReferenceUsageScan(usages.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray(), StringComparer.OrdinalIgnoreCase), authored.ToImmutableArray(), workflows.ToImmutableArray());
+ }
+
+ private static void ScanComponents(IFileAccessor fileAccessor, IReadOnlyList allFiles, Dictionary> usages, HashSet authored, CancellationToken cancellationToken)
+ {
+ foreach (var file in allFiles)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var path = NormalizePath(file.ToString());
+
+ if (!path.EndsWith(ComponentExtension, StringComparison.OrdinalIgnoreCase) && !path.EndsWith(ComponentExtensionLong, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (IsUnder(path, HiddenFolder) || IsConnectionReferencesFile(path))
+ {
+ continue;
+ }
+
+ var content = ReadText(fileAccessor, file);
+ foreach (var value in ConnectionReferenceText.ExtractConnectionReferenceNames(content))
+ {
+ authored.Add(value);
+ AddUsage(usages, value, new ConnectionReferenceUsage
+ {
+ LogicalName = value,
+ FilePath = path,
+ Kind = ClassifyComponent(path),
+ DisplayName = GetFileDisplayName(path),
+ });
+ }
+ }
+ }
+
+ private static void ScanWorkflows(IFileAccessor fileAccessor, IReadOnlyList allFiles, Dictionary> usages, List workflows, CancellationToken cancellationToken)
+ {
+ var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).IgnoreUnmatchedProperties().Build();
+
+ foreach (var (path, content) in EnumerateMetadataFiles(fileAccessor, allFiles, WorkflowsFolder, cancellationToken))
+ {
+ SyncDataverseClient.WorkflowMetadata? metadata;
+ try
+ {
+ metadata = deserializer.Deserialize(content);
+ }
+ catch (YamlDotNet.Core.YamlException)
+ {
+ continue;
+ }
+
+ if (metadata == null)
+ {
+ continue;
+ }
+
+ var connectionNames = (metadata.ConnectionReferences ?? new List()).Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToImmutableArray();
+ if (connectionNames.IsEmpty)
+ {
+ connectionNames = ReadWorkflowJsonConnectionReferences(fileAccessor, path, cancellationToken);
+ }
+
+ var displayName = !string.IsNullOrWhiteSpace(metadata.Name) ? metadata.Name! : GetFileDisplayName(path);
+
+ workflows.Add(new ScannedWorkflow
+ {
+ WorkflowId = metadata.WorkflowId == Guid.Empty ? string.Empty : metadata.WorkflowId.ToString(),
+ DisplayName = displayName,
+ FilePath = path,
+ State = MapWorkflowState(metadata.StateCode, metadata.StatusCode),
+ ConnectionReferenceLogicalNames = connectionNames,
+ });
+
+ foreach (var name in connectionNames)
+ {
+ AddUsage(usages, name, new ConnectionReferenceUsage
+ {
+ LogicalName = name,
+ FilePath = path,
+ Kind = UsageKind.Workflow,
+ DisplayName = displayName,
+ });
+ }
+ }
+ }
+
+ private static ImmutableArray ReadWorkflowJsonConnectionReferences(IFileAccessor fileAccessor, string metadataPath, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var lastSlash = metadataPath.LastIndexOf('/');
+ var workflowJsonPath = lastSlash >= 0 ? metadataPath.Substring(0, lastSlash + 1) + "workflow.json" : "workflow.json";
+
+ var json = ReadText(fileAccessor, new AgentFilePath(workflowJsonPath));
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return ImmutableArray.Empty;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(json!);
+ var root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("properties", out var propertiesElement)
+ || propertiesElement.ValueKind != JsonValueKind.Object
+ || !propertiesElement.TryGetProperty("connectionReferences", out var connectionsElement)
+ || connectionsElement.ValueKind != JsonValueKind.Object)
+ {
+ return ImmutableArray.Empty;
+ }
+
+ var builder = ImmutableArray.CreateBuilder();
+ foreach (var connection in connectionsElement.EnumerateObject())
+ {
+ var value = connection.Value;
+ if (value.ValueKind == JsonValueKind.Object
+ && value.TryGetProperty("connection", out var connectionObj)
+ && connectionObj.ValueKind == JsonValueKind.Object
+ && connectionObj.TryGetProperty("connectionReferenceLogicalName", out var logicalNameElement)
+ && logicalNameElement.ValueKind == JsonValueKind.String)
+ {
+ var logicalName = logicalNameElement.GetString();
+ if (!string.IsNullOrWhiteSpace(logicalName))
+ {
+ builder.Add(logicalName!);
+ }
+ }
+ }
+
+ return builder
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToImmutableArray();
+ }
+ catch (JsonException)
+ {
+ return ImmutableArray.Empty;
+ }
+ }
+
+ private static void ScanConnectors(IFileAccessor fileAccessor, IReadOnlyList allFiles, IReadOnlyDictionary connectorInternalIdByLogicalName, Dictionary> usages, CancellationToken cancellationToken)
+ {
+ if (connectorInternalIdByLogicalName.Count == 0)
+ {
+ return;
+ }
+
+ var logicalNamesByInternalId = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var pair in connectorInternalIdByLogicalName)
+ {
+ if (string.IsNullOrWhiteSpace(pair.Value))
+ {
+ continue;
+ }
+
+ if (!logicalNamesByInternalId.TryGetValue(pair.Value, out var list))
+ {
+ list = new List();
+ logicalNamesByInternalId[pair.Value] = list;
+ }
+
+ list.Add(pair.Key);
+ }
+
+ foreach (var (path, content) in EnumerateMetadataFiles(fileAccessor, allFiles, ConnectorsFolder, cancellationToken))
+ {
+ CustomConnectorMetadata? connector;
+ try
+ {
+ connector = JsonSerializer.Deserialize(content);
+ }
+ catch (JsonException)
+ {
+ continue;
+ }
+
+ if (connector == null || string.IsNullOrWhiteSpace(connector.ConnectorInternalId))
+ {
+ continue;
+ }
+
+ if (!logicalNamesByInternalId.TryGetValue(connector.ConnectorInternalId!, out var logicalNames))
+ {
+ continue;
+ }
+
+ foreach (var logicalName in logicalNames)
+ {
+ AddUsage(usages, logicalName, new ConnectionReferenceUsage
+ {
+ LogicalName = logicalName,
+ FilePath = path,
+ Kind = UsageKind.Connector,
+ DisplayName = !string.IsNullOrWhiteSpace(connector.DisplayName) ? connector.DisplayName! : (!string.IsNullOrWhiteSpace(connector.Name) ? connector.Name! : GetFileDisplayName(path)),
+ });
+ }
+ }
+ }
+
+ private static void AddUsage(Dictionary> usages, string logicalName, ConnectionReferenceUsage usage)
+ {
+ if (!usages.TryGetValue(logicalName, out var list))
+ {
+ list = new List();
+ usages[logicalName] = list;
+ }
+
+ list.Add(usage);
+ }
+
+ private static UsageKind ClassifyComponent(string normalizedPath)
+ {
+ if (IsUnder(normalizedPath, "topics"))
+ {
+ return UsageKind.Topic;
+ }
+
+ return UsageKind.Action;
+ }
+
+ private static WorkflowState MapWorkflowState(int? stateCode, int? statusCode)
+ {
+ if (stateCode == null)
+ {
+ return WorkflowState.Unknown;
+ }
+
+ return stateCode.Value switch
+ {
+ 0 => WorkflowState.Draft,
+ 1 => WorkflowState.Activated,
+ 2 => WorkflowState.Suspended,
+ _ => WorkflowState.Unknown,
+ };
+ }
+
+ private static string? ReadText(IFileAccessor fileAccessor, AgentFilePath path)
+ {
+ try
+ {
+ using var stream = fileAccessor.OpenRead(path);
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static bool IsConnectionReferencesFile(string normalizedPath)
+ {
+ return normalizedPath.EndsWith("/" + ConnectionReferencesFileName, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(normalizedPath, ConnectionReferencesFileName, StringComparison.OrdinalIgnoreCase)
+ || normalizedPath.EndsWith("/" + ConnectionReferencesFileNameLong, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(normalizedPath, ConnectionReferencesFileNameLong, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsUnder(string normalizedPath, string folder)
+ {
+ return normalizedPath.StartsWith(folder + "/", StringComparison.OrdinalIgnoreCase) || normalizedPath.IndexOf("/" + folder + "/", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
+ private static IEnumerable<(string Path, string Content)> EnumerateMetadataFiles(IFileAccessor fileAccessor, IReadOnlyList allFiles, string folder, CancellationToken cancellationToken)
+ {
+ foreach (var file in allFiles)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var path = NormalizePath(file.ToString());
+
+ if (!IsUnder(path, folder) || !path.EndsWith("/metadata.yml", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var content = ReadText(fileAccessor, file);
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ continue;
+ }
+
+ yield return (path, content!);
+ }
+ }
+
+ private static string GetFileDisplayName(string normalizedPath)
+ {
+ var name = normalizedPath;
+ var slash = name.LastIndexOf('/');
+ if (slash >= 0)
+ {
+ name = name.Substring(slash + 1);
+ }
+
+ if (string.Equals(name, "metadata.yml", StringComparison.OrdinalIgnoreCase))
+ {
+ var folder = normalizedPath;
+ var trimmed = folder.EndsWith("/metadata.yml", StringComparison.OrdinalIgnoreCase) ? folder.Substring(0, folder.Length - "/metadata.yml".Length) : folder;
+ var folderSlash = trimmed.LastIndexOf('/');
+ return folderSlash >= 0 ? trimmed.Substring(folderSlash + 1) : trimmed;
+ }
+
+ if (name.EndsWith(ComponentExtensionLong, StringComparison.OrdinalIgnoreCase))
+ {
+ name = name.Substring(0, name.Length - ComponentExtensionLong.Length);
+ }
+ else if (name.EndsWith(ComponentExtension, StringComparison.OrdinalIgnoreCase))
+ {
+ name = name.Substring(0, name.Length - ComponentExtension.Length);
+ }
+
+ return name;
+ }
+
+ private static string NormalizePath(string path)
+ {
+ return path.Replace('\\', '/');
+ }
+}
+
+public sealed class ConnectionReferenceUsageScan
+{
+ internal ConnectionReferenceUsageScan(IReadOnlyDictionary> usagesByLogicalName, ImmutableArray authoredLogicalNames, ImmutableArray workflows)
+ {
+ UsagesByLogicalName = usagesByLogicalName;
+ AuthoredLogicalNames = authoredLogicalNames;
+ Workflows = workflows;
+ }
+
+ public IReadOnlyDictionary> UsagesByLogicalName { get; }
+
+ public ImmutableArray AuthoredLogicalNames { get; }
+
+ public ImmutableArray Workflows { get; }
+
+ public ImmutableArray GetUsages(string logicalName)
+ {
+ return UsagesByLogicalName.TryGetValue(logicalName, out var found) ? found : ImmutableArray.Empty;
+ }
+}
+
+public sealed class ScannedWorkflow
+{
+ public string WorkflowId { get; init; } = string.Empty;
+
+ public string DisplayName { get; init; } = string.Empty;
+
+ public string FilePath { get; init; } = string.Empty;
+
+ public WorkflowState State { get; init; }
+
+ public ImmutableArray ConnectionReferenceLogicalNames { get; init; } = ImmutableArray.Empty;
+}
diff --git a/src/CopilotStudio.Sync/Dataverse/IConnectionCatalogClient.cs b/src/CopilotStudio.Sync/Dataverse/IConnectionCatalogClient.cs
new file mode 100644
index 0000000..2d79f8e
--- /dev/null
+++ b/src/CopilotStudio.Sync/Dataverse/IConnectionCatalogClient.cs
@@ -0,0 +1,26 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using System.Threading;
+
+namespace Microsoft.CopilotStudio.Sync.Dataverse;
+
+///
+/// Lists existing cloud connections for a connector.
+///
+public interface IConnectionCatalogClient
+{
+ ///
+ /// Returns the existing connections of the given connector type in the context's environment.
+ ///
+ /// Power Apps context.
+ /// The connector internal id.
+ /// Cancellation token.
+ Task> ListConnectionsAsync(PowerAppsContext context, string connectorName, CancellationToken cancellationToken);
+
+ ///
+ /// Returns the connectors available in the context's environment.
+ ///
+ /// Power Apps context.
+ /// Cancellation token.
+ Task> ListConnectorsAsync(PowerAppsContext context, CancellationToken cancellationToken);
+}
\ No newline at end of file
diff --git a/src/CopilotStudio.Sync/Dataverse/ISyncDataverseClient.cs b/src/CopilotStudio.Sync/Dataverse/ISyncDataverseClient.cs
index 710b02f..11cfde2 100644
--- a/src/CopilotStudio.Sync/Dataverse/ISyncDataverseClient.cs
+++ b/src/CopilotStudio.Sync/Dataverse/ISyncDataverseClient.cs
@@ -64,6 +64,12 @@ public interface ISyncDataverseClient
///
Task BindConnectionReferenceAsync(string connectionReferenceLogicalName, string connectionLogicalName, CancellationToken cancellationToken, string? connectionReferenceDisplayName = null);
+ ///
+ /// Sets a workflow's activation state. When activate is true the workflow is activated
+ /// (statecode 1, statuscode 2); otherwise it is set back to draft (statecode 0, statuscode 1).
+ ///
+ Task SetWorkflowStateAsync(Guid workflowId, bool activate, CancellationToken cancellationToken);
+
///
/// Get connection references by logical names.
///
diff --git a/src/CopilotStudio.Sync/Dataverse/PowerAppsClient.cs b/src/CopilotStudio.Sync/Dataverse/PowerAppsClient.cs
new file mode 100644
index 0000000..d78dac6
--- /dev/null
+++ b/src/CopilotStudio.Sync/Dataverse/PowerAppsClient.cs
@@ -0,0 +1,297 @@
+// Copyright (C) Microsoft Corporation. All rights reserved.
+
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+
+namespace Microsoft.CopilotStudio.Sync.Dataverse;
+
+public class PowerAppsClient : IConnectionCatalogClient
+{
+ private const string ConnectionsApiVersion = "2016-11-01";
+ private const int MaxCatalogPages = 50;
+ private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
+ private readonly HttpClient _httpClient;
+ private readonly string _userAgent;
+ private readonly TimeSpan _requestTimeout;
+
+ private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public PowerAppsClient(HttpClient httpClient, string userAgent = "CopilotStudio.Sync", TimeSpan? requestTimeout = null)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _userAgent = userAgent;
+ _requestTimeout = requestTimeout ?? DefaultRequestTimeout;
+ }
+
+ public async Task> ListConnectionsAsync(PowerAppsContext context, string connectorName, CancellationToken cancellationToken)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ var normalizedConnector = LastSegment(connectorName);
+ if (string.IsNullOrWhiteSpace(normalizedConnector) || string.IsNullOrWhiteSpace(context.EnvironmentId))
+ {
+ return Array.Empty();
+ }
+
+ var requestUri = BuildApisRequestUri(context, $"/{normalizedConnector}/connections");
+ var result = new List();
+ var pageCount = 0;
+ while (!string.IsNullOrEmpty(requestUri) && pageCount < MaxCatalogPages)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var parsed = await SendRequestAsync