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(requestUri!, context.AccessToken, "Connections", cancellationToken).ConfigureAwait(false); + if (parsed?.Value != null) + { + foreach (var item in parsed.Value) + { + if (item == null || string.IsNullOrWhiteSpace(item.Name)) + { + continue; + } + + var props = item.Properties; + var name = item.Name!; + result.Add(new ConnectionInstance + { + Name = name, + DisplayName = string.IsNullOrWhiteSpace(props?.DisplayName) ? name : props!.DisplayName!, + Status = ExtractStatus(props), + Owner = ExtractOwner(props), + }); + } + } + + requestUri = parsed?.NextLink; + pageCount++; + } + + return result; + } + + public async Task> ListConnectorsAsync(PowerAppsContext context, CancellationToken cancellationToken) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (string.IsNullOrWhiteSpace(context.EnvironmentId)) + { + return Array.Empty(); + } + + var requestUri = BuildApisRequestUri(context, string.Empty); + var result = new List(); + var pageCount = 0; + while (!string.IsNullOrEmpty(requestUri) && pageCount < MaxCatalogPages) + { + cancellationToken.ThrowIfCancellationRequested(); + var parsed = await SendRequestAsync(requestUri!, context.AccessToken, "Connectors", cancellationToken).ConfigureAwait(false); + if (parsed?.Value != null) + { + foreach (var item in parsed.Value) + { + if (item == null || string.IsNullOrWhiteSpace(item.Name)) + { + continue; + } + + var props = item.Properties; + var name = item.Name!; + result.Add(new ConnectorInfo + { + InternalId = name, + DisplayName = string.IsNullOrWhiteSpace(props?.DisplayName) ? name : props!.DisplayName!, + Publisher = props?.Publisher ?? string.Empty, + Tier = props?.Tier ?? string.Empty, + IconUri = props?.IconUri ?? string.Empty, + }); + } + } + + requestUri = parsed?.NextLink; + pageCount++; + } + + return result; + } + + private string BuildApisRequestUri(PowerAppsContext context, string pathSuffix) + { + var host = ResolveHost(context.ClusterCategory); + var filter = Uri.EscapeDataString($"environment eq '{context.EnvironmentId.Replace("'", "''")}'"); + return $"https://{host}/providers/Microsoft.PowerApps/apis{pathSuffix}?api-version={ConnectionsApiVersion}&$filter={filter}"; + } + + private async Task SendRequestAsync(string requestUri, string accessToken, string operationLabel, CancellationToken cancellationToken) + { + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri); + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + requestMessage.Headers.UserAgent.ParseAdd(_userAgent); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_requestTimeout); + + try + { + using var responseMessage = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token).ConfigureAwait(false); + if (!responseMessage.IsSuccessStatusCode) + { + throw new InvalidOperationException($"{operationLabel} request failed ({(int)responseMessage.StatusCode})."); + } + + using var responseStream = await responseMessage.Content.ReadAsStreamAsync(timeoutCts.Token).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions, timeoutCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"{operationLabel} request timed out after {_requestTimeout.TotalSeconds:0}s."); + } + } + + private static string ExtractStatus(ConnectionProperties? props) + { + if (props?.Statuses == null || props.Statuses.Length == 0) + { + return string.Empty; + } + + return props.Statuses[0]?.Status ?? string.Empty; + } + + private static string ExtractOwner(ConnectionProperties? props) + { + var createdBy = props?.CreatedBy; + if (createdBy == null) + { + return string.Empty; + } + + if (!string.IsNullOrWhiteSpace(createdBy.Email)) + { + return createdBy.Email!; + } + + return createdBy.DisplayName ?? string.Empty; + } + + private static string LastSegment(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var trimmed = value!.Trim().TrimEnd('/'); + var slash = trimmed.LastIndexOf('/'); + return slash >= 0 ? trimmed.Substring(slash + 1) : trimmed; + } + + private static string ResolveHost(CoreServicesClusterCategory clusterCategory) => clusterCategory switch + { + CoreServicesClusterCategory.Gov => "gov.api.powerapps.us", + CoreServicesClusterCategory.GovFR => "gov.api.powerapps.us", + CoreServicesClusterCategory.High => "high.api.powerapps.us", + CoreServicesClusterCategory.DoD => "api.apps.appsplatform.us", + CoreServicesClusterCategory.Mooncake => "api.powerapps.cn", + CoreServicesClusterCategory.Ex => "api.powerapps.eaglex.ic.gov", + CoreServicesClusterCategory.Rx => "api.powerapps.microsoft.scloud", + _ => "api.powerapps.com", + }; + + private sealed class ConnectionListResponse + { + [JsonPropertyName("value")] + public ConnectionListItem[]? Value { get; set; } + + [JsonPropertyName("@odata.nextLink")] + public string? NextLink { get; set; } + } + + private sealed class ConnectionListItem + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("properties")] + public ConnectionProperties? Properties { get; set; } + } + + private sealed class ConnectionProperties + { + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("statuses")] + public ConnectionStatus[]? Statuses { get; set; } + + [JsonPropertyName("createdBy")] + public ConnectionPrincipal? CreatedBy { get; set; } + } + + private sealed class ConnectionStatus + { + [JsonPropertyName("status")] + public string? Status { get; set; } + } + + private sealed class ConnectionPrincipal + { + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("email")] + public string? Email { get; set; } + } + + private sealed class ConnectorListResponse + { + [JsonPropertyName("value")] + public ConnectorListItem[]? Value { get; set; } + + [JsonPropertyName("@odata.nextLink")] + public string? NextLink { get; set; } + } + + private sealed class ConnectorListItem + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("properties")] + public ConnectorProperties? Properties { get; set; } + } + + private sealed class ConnectorProperties + { + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("publisher")] + public string? Publisher { get; set; } + + [JsonPropertyName("tier")] + public string? Tier { get; set; } + + [JsonPropertyName("iconUri")] + public string? IconUri { get; set; } + } +} + +public sealed class PowerAppsContext +{ + public string AccessToken { get; init; } = string.Empty; + + public string EnvironmentId { get; init; } = string.Empty; + + public CoreServicesClusterCategory ClusterCategory { get; init; } +} diff --git a/src/CopilotStudio.Sync/Dataverse/SyncDataverseClient.cs b/src/CopilotStudio.Sync/Dataverse/SyncDataverseClient.cs index a4d874a..0eb6510 100644 --- a/src/CopilotStudio.Sync/Dataverse/SyncDataverseClient.cs +++ b/src/CopilotStudio.Sync/Dataverse/SyncDataverseClient.cs @@ -136,6 +136,7 @@ public virtual async Task InsertWorkflowAsync(Guid? agentId, W var errorMessage = string.Empty; try { + var shouldActivate = workflowMetadata.StateCode != 0; workflowMetadata.StateCode = null; workflowMetadata.StatusCode = null; var requestBody = CreateWorkflowRequestBody(workflowMetadata); @@ -149,9 +150,17 @@ public virtual async Task InsertWorkflowAsync(Guid? agentId, W cancellationToken ).ConfigureAwait(false); - await ActivateWorkflowAsync(workflowMetadata.WorkflowId, cancellationToken).ConfigureAwait(false); - workflowMetadata.StateCode = 1; - workflowMetadata.StatusCode = 2; + if (shouldActivate) + { + await ActivateWorkflowAsync(workflowMetadata.WorkflowId, cancellationToken).ConfigureAwait(false); + workflowMetadata.StateCode = 1; + workflowMetadata.StatusCode = 2; + } + else + { + workflowMetadata.StateCode = 0; + workflowMetadata.StatusCode = 1; + } } catch (OperationCanceledException) { @@ -430,6 +439,18 @@ private async Task ActivateWorkflowAsync(Guid workflowId, CancellationToken canc await SendAsync(HttpMethodHelper.Patch, activateUrl, activateBody, false, cancellationToken).ConfigureAwait(false); } + public virtual async Task SetWorkflowStateAsync(Guid workflowId, bool activate, CancellationToken cancellationToken) + { + var url = $"{DataverseUrl}/api/data/v9.2/workflows({workflowId})"; + var body = new Dictionary + { + ["statecode"] = activate ? 1 : 0, + ["statuscode"] = activate ? 2 : 1, + }; + + await SendAsync(HttpMethodHelper.Patch, url, body, false, cancellationToken).ConfigureAwait(false); + } + private async Task> GetAllBotComponentIdsAsync(AgentSyncInfo syncInfo, CancellationToken cancellationToken) { string url = string.Empty; @@ -730,11 +751,7 @@ public virtual async Task GetAgentInfoAsync(Guid agentId, Cancellatio }; } - public virtual async Task EnsureConnectionReferenceExistsAsync( - string connectionReferenceLogicalName, - string connectorId, - CancellationToken cancellationToken, - Guid? customConnectorRowId = null) + public virtual async Task EnsureConnectionReferenceExistsAsync(string connectionReferenceLogicalName, string connectorId, CancellationToken cancellationToken, Guid? customConnectorRowId = null) { var existing = await GetConnectionReferenceByLogicalNameAsync(connectionReferenceLogicalName, cancellationToken).ConfigureAwait(false); if (existing is null) @@ -745,9 +762,7 @@ public virtual async Task EnsureConnectionReferenceExistsAsync( var desiredInternalId = ExtractConnectorInternalId(connectorId); var existingInternalId = ExtractConnectorInternalId(existing.ConnectorId); - if (!string.IsNullOrWhiteSpace(desiredInternalId) && - !string.IsNullOrWhiteSpace(existingInternalId) && - !string.Equals(existingInternalId, desiredInternalId, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(desiredInternalId) && !string.IsNullOrWhiteSpace(existingInternalId) && !string.Equals(existingInternalId, desiredInternalId, StringComparison.OrdinalIgnoreCase)) { await UpdateConnectionReferenceConnectorAsync(existing.ConnectionReferenceId, connectorId, customConnectorRowId, cancellationToken).ConfigureAwait(false); } @@ -798,13 +813,6 @@ private async Task UpdateConnectionReferenceConnectorAsync(Guid connectionRefere await SendAsync(HttpMethodHelper.Patch, patchUri.ToString(), body, false, cancellationToken).ConfigureAwait(false); } - /// - /// Binds a connection reference to a connection by setting its connectionid. - /// - /// Logical name of the connection reference to bind. - /// The bound connection's logical name. - /// Cancellation token. - /// Optional display name to set on the reference. public virtual async Task BindConnectionReferenceAsync(string connectionReferenceLogicalName, string connectionLogicalName, CancellationToken cancellationToken, string? connectionReferenceDisplayName = null) { if (connectionReferenceLogicalName is null) @@ -831,6 +839,7 @@ public virtual async Task BindConnectionReferenceAsync(string connectionReferenc } var connectionReferenceId = existing[0].ConnectionReferenceId; + var patchUri = new Uri(new Uri(DataverseUrl), $"/api/data/v9.2/connectionreferences({connectionReferenceId})"); var body = new Dictionary @@ -1321,6 +1330,9 @@ public class WorkflowMetadata [YamlIgnore] [JsonPropertyName("clientdata")] public string? ClientData { get; set; } + + [JsonIgnore] + public List ConnectionReferences { get; set; } = new(); } public class ManagedProperty diff --git a/src/CopilotStudio.Sync/IConnectionManagementService.cs b/src/CopilotStudio.Sync/IConnectionManagementService.cs new file mode 100644 index 0000000..deb8b97 --- /dev/null +++ b/src/CopilotStudio.Sync/IConnectionManagementService.cs @@ -0,0 +1,135 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +using Microsoft.Agents.ObjectModel; +using Microsoft.CopilotStudio.McsCore; +using Microsoft.CopilotStudio.Sync.Dataverse; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.CopilotStudio.Sync; + +/// +/// Optional connection-management surface used by the Visual Studio Code connection manager. +/// This is intentionally separate from so that hosts which +/// only need core sync (clone/pull/push) are not required to implement connection-management +/// behavior. Hosts opt in by resolving this service; implements it. +/// +public interface IConnectionManagementService +{ + /// + /// Get the agent's connection references. + /// + /// Workspace folder. + /// The bot definition. + /// Dataverse client used to read binding state. + /// Client used to list existing cloud connections per connector. + /// Power Apps context. + /// Cancellation token. + /// The declared connection references with binding state and selectable candidates. + Task> GetAgentConnectionViewsAsync( + DirectoryPath workspaceFolder, + DefinitionBase definition, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient catalogClient, + PowerAppsContext catalogContext, + CancellationToken cancellationToken); + + /// + /// Binds connection references to existing cloud connections. + /// + /// Workspace folder. + /// The bot definition. + /// Dataverse client used to bind and read binding state. + /// Client used to list existing cloud connections per connector. + /// Power Apps context. + /// The connection reference to connection bindings to apply. + /// Cancellation token. + /// The refreshed connection views after binding. + Task> ApplyConnectionBindingsAsync( + DirectoryPath workspaceFolder, + DefinitionBase definition, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient catalogClient, + PowerAppsContext catalogContext, + IReadOnlyList bindings, + CancellationToken cancellationToken); + + /// + /// Writes the connection views to the connections cache. + /// + /// Workspace folder. + /// The connection views to cache. + void WriteConnectionsCache(DirectoryPath workspaceFolder, IReadOnlyList views); + + /// + /// Get the current generation of the workspace's connections cache. + /// + /// The workspace folder. + /// The current cache generation. + long GetConnectionsCacheGeneration(DirectoryPath workspaceFolder); + + /// + /// Writes the connection views to the connections cache. + /// + /// The workspace folder. + /// The connection views to persist. + /// The generation captured before the read that produced views. + /// True when the cache was written; false when a newer write made the views stale. + bool TryWriteConnectionsCache(DirectoryPath workspaceFolder, IReadOnlyList views, long expectedGeneration); + + /// + /// Declares connection references and writes them into the declared connection reference files. + /// + /// Workspace folder. + /// The bot definition. + /// The connection reference logical names to declare. + /// Dataverse client used to create the connection reference records. + /// Cancellation token. + /// The declared and invalid logical names. + Task DeclareConnectionReferencesAsync( + DirectoryPath workspaceFolder, + DefinitionBase definition, + IReadOnlyList logicalNames, + ISyncDataverseClient dataverseClient, + CancellationToken cancellationToken); + + /// + /// Creates and declares a new connection reference for the given connector. + /// + /// Workspace folder. + /// The bot definition. + /// The connector internal id to create the reference for (for example shared_office365). + /// Dataverse client used to create the connection reference record. + /// Cancellation token. + /// The new connection reference logical name. + Task CreateConnectionReferenceForConnectorAsync( + DirectoryPath workspaceFolder, + DefinitionBase definition, + string connectorInternalId, + ISyncDataverseClient dataverseClient, + CancellationToken cancellationToken); + + /// + /// Removes a connection reference declaration from the workspace's local connection reference files. + /// + /// Workspace folder. + /// The bot definition. + /// The connection reference logical name to remove. + /// True to remove even when the reference is still used by a component. + /// Cancellation token. + /// The removal result; when blocked by usages and not confirmed, the blocking usages. + Task RemoveConnectionReferenceAsync( + DirectoryPath workspaceFolder, + DefinitionBase definition, + string logicalName, + bool confirmed, + CancellationToken cancellationToken); + + /// + /// Reads the on-disk connections cache, or returns null when it is missing or unreadable. + /// + /// Workspace folder that owns the cache. + /// The cached connection views, or null. + ConnectionsCacheFile? ReadConnectionsCache(DirectoryPath workspaceFolder); +} diff --git a/src/CopilotStudio.Sync/IWorkflowActivationService.cs b/src/CopilotStudio.Sync/IWorkflowActivationService.cs new file mode 100644 index 0000000..708969b --- /dev/null +++ b/src/CopilotStudio.Sync/IWorkflowActivationService.cs @@ -0,0 +1,36 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +using Microsoft.CopilotStudio.McsCore; +using Microsoft.CopilotStudio.Sync.Dataverse; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.CopilotStudio.Sync; + +/// +/// Optional workflow activation surface used by the Visual Studio Code connection manager to +/// enable or disable an agent's cloud flows. This is intentionally separate from +/// so that hosts which only need core sync are not required +/// to implement it. implements it. +/// +public interface IWorkflowActivationService +{ + /// + /// Get the agent's workflow activation status. + /// + /// Workspace folder that owns the workflows. + /// The current connection views used to determine whether each workflow can be enabled. + /// The workflow status views. + IReadOnlyList GetWorkflowStatusViews(DirectoryPath workspaceFolder, IReadOnlyList views); + + /// + /// Activates or deactivates one or more workflows in a single pass. + /// + /// Workspace folder that owns the workflows. + /// The per-workflow activation requests to apply. + /// Dataverse client used to change the workflow states. + /// Cancellation token. + /// The result of the batch with the refreshed workflow status views. + Task SetWorkflowActivationsAsync(DirectoryPath workspaceFolder, IReadOnlyList requests, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken); +} diff --git a/src/CopilotStudio.Sync/IWorkspaceSynchronizer.cs b/src/CopilotStudio.Sync/IWorkspaceSynchronizer.cs index 50f292e..454c5b8 100644 --- a/src/CopilotStudio.Sync/IWorkspaceSynchronizer.cs +++ b/src/CopilotStudio.Sync/IWorkspaceSynchronizer.cs @@ -222,12 +222,14 @@ Task SyncWorkspaceAsync( /// The dataverse client to use for communication with the dataverse service. /// The Id for the agent. /// Cancellation token to cancel the operation. + /// Workflow activation mode. /// List of workflow responses. Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync( DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, - CancellationToken cancellationToken); + CancellationToken cancellationToken, + WorkflowActivationMode activationMode = WorkflowActivationMode.PreserveSavedState); /// /// Pull AI Builder prompt models for agent @@ -339,6 +341,12 @@ Task> GetNewAgentConnectionReferencesAsync( ISyncDataverseClient dataverseClient, CancellationToken cancellationToken); + /// + /// Deletes the local component sync baselines (custom connector, knowledge file, and AI prompt content hashes). + /// + /// The location of the root of the workspace + void ClearComponentSyncBaselines(DirectoryPath workspaceFolder); + /// /// Pushes local custom connector edits to Dataverse. /// Creates the connector remotely if it doesn't already exist. diff --git a/src/CopilotStudio.Sync/Models.cs b/src/CopilotStudio.Sync/Models.cs index b042b24..028d652 100644 --- a/src/CopilotStudio.Sync/Models.cs +++ b/src/CopilotStudio.Sync/Models.cs @@ -234,6 +234,149 @@ public class ConnectionNeeded #endregion +#region Connection management + +public class ConnectionInstance +{ + public string Name { get; init; } = string.Empty; + + public string DisplayName { get; init; } = string.Empty; + + public string Status { get; init; } = string.Empty; + + public string Owner { get; init; } = string.Empty; +} + +public class ConnectorInfo +{ + public string InternalId { get; init; } = string.Empty; + + public string DisplayName { get; init; } = string.Empty; + + public string Publisher { get; init; } = string.Empty; + + public string Tier { get; init; } = string.Empty; + + public string IconUri { get; init; } = string.Empty; +} + +public class AgentConnectionView +{ + public string ConnectionReferenceLogicalName { get; init; } = string.Empty; + + public string ConnectorId { get; init; } = string.Empty; + + public string ConnectorName { get; init; } = string.Empty; + + public string BoundConnectionId { get; init; } = string.Empty; + + public bool BoundConnectionExists { get; init; } + + public ImmutableArray Candidates { get; init; } = ImmutableArray.Empty; + + public ImmutableArray Usages { get; init; } = ImmutableArray.Empty; + + public bool IsDeclared { get; init; } = true; + + public bool CatalogUnavailable { get; init; } +} + +public enum UsageKind +{ + Action, + Topic, + Workflow, + Connector, + ConnectionReferencesFile, + BotDefinition, +} + +public class ConnectionReferenceUsage +{ + public string LogicalName { get; init; } = string.Empty; + + public string FilePath { get; init; } = string.Empty; + + public UsageKind Kind { get; init; } + + public string DisplayName { get; init; } = string.Empty; +} + +public enum WorkflowState +{ + Unknown, + Draft, + Activated, + Suspended, +} + +public class WorkflowStatusView +{ + 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; + + public bool CanEnable { get; init; } +} + +public class WorkflowActivationResult +{ + public bool Succeeded { get; init; } + + public string? Message { get; init; } + + public ImmutableArray Workflows { get; init; } = ImmutableArray.Empty; +} + +public class WorkflowActivationRequest +{ + public Guid WorkflowId { get; init; } + + public bool Activate { get; init; } +} + +public class ConnectionReferenceRemovalResult +{ + public bool Removed { get; init; } + + public ImmutableArray Usages { get; init; } = ImmutableArray.Empty; +} + +public class DeclareConnectionReferencesResult +{ + public ImmutableArray Declared { get; init; } = ImmutableArray.Empty; + + public ImmutableArray Invalid { get; init; } = ImmutableArray.Empty; +} + +public class ConnectionBindingRequest +{ + public string ConnectionReferenceLogicalName { get; init; } = string.Empty; + + public string ConnectionId { get; init; } = string.Empty; + + public string? ConnectionDisplayName { get; init; } +} + +public class ConnectionsCacheFile +{ + public string SchemaVersion { get; init; } = "2"; + + public DateTimeOffset RefreshedAt { get; init; } + + public ImmutableArray Connections { get; init; } = ImmutableArray.Empty; + + public ImmutableArray Workflows { get; init; } = ImmutableArray.Empty; +} + +#endregion + #region CloudFlowMetadata public class CloudFlowMetadata @@ -241,6 +384,12 @@ public class CloudFlowMetadata public ImmutableArray Workflows { get; init; } = ImmutableArray.Empty; public ImmutableArray ConnectionReferences { get; init; } = ImmutableArray.Empty; + + /// + /// True when the workflow download completed without error. False indicates the result is + /// non-authoritative (a transient failure) and must not be treated as the cloud's full workflow set. + /// + public bool Succeeded { get; init; } = true; } #endregion @@ -352,6 +501,15 @@ public class WorkspaceSyncInfo #region WorkflowResponse +public enum WorkflowActivationMode +{ + PreserveSavedState, + + ActivateWhenConnectionsBound, + + DraftWhenConnectionsUnbound, +} + public class WorkflowResponse { public string WorkflowName { get; init; } = string.Empty; diff --git a/src/CopilotStudio.Sync/SyncServiceRegistrations.cs b/src/CopilotStudio.Sync/SyncServiceRegistrations.cs index ed425e3..585bf4c 100644 --- a/src/CopilotStudio.Sync/SyncServiceRegistrations.cs +++ b/src/CopilotStudio.Sync/SyncServiceRegistrations.cs @@ -35,6 +35,9 @@ public static void AddSyncServices(this IServiceCollection services, string user services.AddSingleton(LspProjectorService.Instance); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); } } diff --git a/src/CopilotStudio.Sync/WorkspaceSynchronizer.cs b/src/CopilotStudio.Sync/WorkspaceSynchronizer.cs index f41ef56..0b81547 100644 --- a/src/CopilotStudio.Sync/WorkspaceSynchronizer.cs +++ b/src/CopilotStudio.Sync/WorkspaceSynchronizer.cs @@ -10,9 +10,11 @@ using Microsoft.Agents.Platform.Content; using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using static Microsoft.CopilotStudio.Sync.Dataverse.SyncDataverseClient; @@ -25,7 +27,7 @@ namespace Microsoft.CopilotStudio.Sync; /// . /// Handles components, change tokens, etc. /// -internal class WorkspaceSynchronizer : IWorkspaceSynchronizer +internal class WorkspaceSynchronizer : IWorkspaceSynchronizer, IConnectionManagementService, IWorkflowActivationService { // We write internal state to our own hidden folder. // We can version this later by appending a version number subdir. @@ -37,6 +39,8 @@ internal class WorkspaceSynchronizer : IWorkspaceSynchronizer // Maximum allowed size for a workflow upload 125 MB (workflow.json + metadata.yml). private const long MaxWorkflowUploadSizeBytes = 125L * 1024 * 1024; + private const int MaxConnectionCatalogConcurrency = 8; + // Folder where environment variables are projected. private const string EnvironmentVariablesFolder = "environmentvariables"; @@ -62,6 +66,14 @@ internal class WorkspaceSynchronizer : IWorkspaceSynchronizer // - original contents - for providing diff; and reverting. private static readonly AgentFilePath BotCachePath = new AgentFilePath(HiddenRoot + "/botdefinition.json"); + private static readonly AgentFilePath ConnectionsCachePath = new AgentFilePath(HiddenRoot + "/.connections-cache.json"); + + private static readonly AgentFilePath ConnectorsSyncStatePath = new AgentFilePath(HiddenRoot + "/.connectors-sync.json"); + + private static readonly AgentFilePath KnowledgeSyncStatePath = new AgentFilePath(HiddenRoot + "/.knowledge-sync.json"); + + private static readonly AgentFilePath AiPromptsSyncStatePath = new AgentFilePath(HiddenRoot + "/.aiprompts-sync.json"); + // Capture the full BotDefinition. // This includes key information like: // - BotComponentId, Version (for later upload) @@ -115,6 +127,9 @@ internal class WorkspaceSynchronizer : IWorkspaceSynchronizer private readonly IIslandControlPlaneService _islandControlPlaneService; private readonly ISyncProgress _syncProgress; + private readonly object _connectionsCacheGate = new object(); + private readonly Dictionary _connectionsCacheGeneration = new Dictionary(StringComparer.OrdinalIgnoreCase); + public WorkspaceSynchronizer( IMcsFileParser fileParser, IFileAccessorFactory writer, @@ -260,13 +275,15 @@ public async Task PullExistingChangesAsync( var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); var workflows = await GetWorkflowsAsync(workspaceFolder, dataverseClient, syncInfo, fileAccessor, cancellationToken).ConfigureAwait(false); - var mergedConnectionReferences = previousDefinition.ConnectionReferences.Concat(workflows.ConnectionReferences) - .Where(cr => !string.IsNullOrEmpty(cr.ConnectionReferenceLogicalName.Value)) - .GroupBy(cr => cr.ConnectionReferenceLogicalName.Value, StringComparer.OrdinalIgnoreCase) - .Select(g => g.Last()) - .ToList(); - previousDefinition = previousDefinition.WithFlows(workflows.Workflows).WithConnectionReferences(mergedConnectionReferences); - + if (workflows.Succeeded) + { + var mergedConnectionReferences = previousDefinition.ConnectionReferences.Concat(workflows.ConnectionReferences) + .Where(cr => !string.IsNullOrEmpty(cr.ConnectionReferenceLogicalName.Value)) + .GroupBy(cr => cr.ConnectionReferenceLogicalName.Value, StringComparer.OrdinalIgnoreCase) + .Select(g => g.Last()) + .ToList(); + previousDefinition = previousDefinition.WithFlows(workflows.Workflows).WithConnectionReferences(mergedConnectionReferences); + } var aiPrompts = await GetAIPromptsAsync(workspaceFolder, dataverseClient, syncInfo, fileAccessor, cancellationToken).ConfigureAwait(false); if (!aiPrompts.IsDefaultOrEmpty) { @@ -281,8 +298,12 @@ public async Task PullExistingChangesAsync( var originalSnapshot = ReadCloudCacheSnapshot(fileAccessor); + var currentWorkflowIds = workflows.Workflows.IsDefaultOrEmpty ? new HashSet() : new HashSet(workflows.Workflows.Select(f => f.WorkflowId.Value)); + var workflowRemoved = workflows.Succeeded && originalSnapshot != null && !originalSnapshot.Flows.IsDefaultOrEmpty && originalSnapshot.Flows.Any(f => !currentWorkflowIds.Contains(f.WorkflowId.Value)); + var connectionReferencesNeedOverride = workflowRemoved || remoteChangeset.BotComponentChanges.OfType().Any(); + // Apply raw changeSet on cloud cache - var (newSnapshot, _) = UpdateCloudCache(fileAccessor, remoteChangeset, workflows, aiPrompts, agentId: syncInfo.AgentId); + var (newSnapshot, _) = UpdateCloudCache(fileAccessor, remoteChangeset, workflows.Succeeded ? workflows : null, aiPrompts, agentId: syncInfo.AgentId, overrideConnectionReferences: connectionReferencesNeedOverride); // Persist new delta token await WriteChangeTokenAsync(fileAccessor, remoteChangeset, cancellationToken).ConfigureAwait(false); @@ -338,10 +359,12 @@ await dataverseClient.DownloadKnowledgeFileAsync( ).ConfigureAwait(false); }).ConfigureAwait(false); #endif + + RecordKnowledgeFilesBaseline(fileAccessor, newSnapshot, fileComponents); } // persist updated change set on directory - var updatedDefinition = await UpdateWorkspaceDirectoryAsync(fileAccessor, updatedChangeSet, previousDefinition, deletedComponents.ToArray(), cancellationToken: cancellationToken, pathGroundingDefinition: newSnapshot).ConfigureAwait(false); + var updatedDefinition = await UpdateWorkspaceDirectoryAsync(fileAccessor, updatedChangeSet, previousDefinition, deletedComponents.ToArray(), cancellationToken: cancellationToken, pathGroundingDefinition: newSnapshot, overrideConnectionReferences: connectionReferencesNeedOverride || deletedComponents.Count > 0).ConfigureAwait(false); var connectorResolvedDefinition = await ResolveConnectionReferenceConnectorIdsAsync(updatedDefinition, dataverseClient, cancellationToken).ConfigureAwait(false); await WriteCustomConnectorsAsync(fileAccessor, workspaceFolder, connectorResolvedDefinition, dataverseClient, cancellationToken).ConfigureAwait(false); @@ -704,6 +727,10 @@ public async Task PushChangesetAsync( }; } + var baseline = ReadKnowledgeSyncState(fileAccessor); + var newHashes = new ConcurrentDictionary(baseline, StringComparer.OrdinalIgnoreCase); + var uploaded = new ConcurrentBag(); + // #if kept: net10 uses Parallel.ForEachAsync with MaxDegreeOfParallelism=5 // for concurrent knowledge-file uploads; netstandard2.0 has no equivalent // and the LCD foreach loses ~5x throughput. Cost is real, so we preserve @@ -712,26 +739,7 @@ public async Task PushChangesetAsync( foreach (var newFileComponent in newFileComponents) { cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrEmpty(newFileComponent.DisplayName)) - { - continue; - } - - var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(newFileComponent, snapshot)); - if (!IsValidFileToUpload(fileAccessor, GetKnowledgeContentFilePath(componentPath, newFileComponent.DisplayName!))) - { - continue; - } - - // ns2.0 BCL's IsNullOrEmpty lacks NotNullWhen; ! is compile-time only. - await dataverseClient.UploadKnowledgeFileAsync( - Path.Combine(workspaceFolder.ToString(), componentPath.ParentDirectoryName), - newFileComponent.Id.Value, - newFileComponent.DisplayName!, - cancellationToken - ).ConfigureAwait(false); - - numberOfUploadedFiles++; + await UploadKnowledgeFileIfChangedAsync(fileAccessor, workspaceFolder, snapshot, newFileComponent, baseline, newHashes, uploaded, dataverseClient, cancellationToken).ConfigureAwait(false); } #else await Parallel.ForEachAsync(newFileComponents, new ParallelOptions @@ -741,27 +749,15 @@ await dataverseClient.UploadKnowledgeFileAsync( }, async (newFileComponent, cancellationToken) => { - if (string.IsNullOrEmpty(newFileComponent.DisplayName)) - { - return; - } - - var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(newFileComponent, snapshot)); - if (!IsValidFileToUpload(fileAccessor, GetKnowledgeContentFilePath(componentPath, newFileComponent.DisplayName!))) - { - return; - } - - await dataverseClient.UploadKnowledgeFileAsync( - Path.Combine(workspaceFolder.ToString(), componentPath.ParentDirectoryName), - newFileComponent.Id.Value, - newFileComponent.DisplayName, - cancellationToken - ).ConfigureAwait(false); - - Interlocked.Increment(ref numberOfUploadedFiles); + await UploadKnowledgeFileIfChangedAsync(fileAccessor, workspaceFolder, snapshot, newFileComponent, baseline, newHashes, uploaded, dataverseClient, cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); #endif + + numberOfUploadedFiles = uploaded.Count; + if (numberOfUploadedFiles > 0) + { + WriteKnowledgeSyncState(fileAccessor, newHashes); + } } return new PushChangesetResult @@ -858,6 +854,8 @@ public async Task> DownloadKnowledgeFilesAsync }).ConfigureAwait(false); #endif + RecordKnowledgeFilesBaseline(fileAccessor, snapshot, fileComponents); + return downloaded.ToImmutableArray(); } @@ -875,24 +873,15 @@ public async Task> UploadKnowledgeFilesAsync(DirectoryPat .Where(c => !string.IsNullOrEmpty(c.DisplayName)) .ToList(); + var baseline = ReadKnowledgeSyncState(fileAccessor); + var newHashes = new ConcurrentDictionary(baseline, StringComparer.OrdinalIgnoreCase); var uploaded = new ConcurrentBag(); #if NETSTANDARD2_0 foreach (var component in fileComponents) { cancellationToken.ThrowIfCancellationRequested(); - var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(component, snapshot)); - if (!IsValidFileToUpload(fileAccessor, GetKnowledgeContentFilePath(componentPath, component.DisplayName!))) - { - continue; - } - - await dataverseClient.UploadKnowledgeFileAsync( - Path.Combine(workspaceFolder.ToString(), componentPath.ParentDirectoryName), - component.Id.Value, - component.DisplayName!, - cancellationToken).ConfigureAwait(false); - uploaded.Add(component.DisplayName!); + await UploadKnowledgeFileIfChangedAsync(fileAccessor, workspaceFolder, snapshot, component, baseline, newHashes, uploaded, dataverseClient, cancellationToken).ConfigureAwait(false); } #else await Parallel.ForEachAsync(fileComponents, new ParallelOptions @@ -901,24 +890,63 @@ await dataverseClient.UploadKnowledgeFileAsync( CancellationToken = cancellationToken }, async (component, ct) => { - var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(component, snapshot)); - if (!IsValidFileToUpload(fileAccessor, GetKnowledgeContentFilePath(componentPath, component.DisplayName!))) - { - return; - } - - await dataverseClient.UploadKnowledgeFileAsync( - Path.Combine(workspaceFolder.ToString(), componentPath.ParentDirectoryName), - component.Id.Value, - component.DisplayName!, - ct).ConfigureAwait(false); - uploaded.Add(component.DisplayName!); + await UploadKnowledgeFileIfChangedAsync(fileAccessor, workspaceFolder, snapshot, component, baseline, newHashes, uploaded, dataverseClient, ct).ConfigureAwait(false); }).ConfigureAwait(false); + #endif + if (!uploaded.IsEmpty) + { + WriteKnowledgeSyncState(fileAccessor, newHashes); + } + return uploaded.ToImmutableArray(); } + private async Task UploadKnowledgeFileIfChangedAsync( + IFileAccessor fileAccessor, + DirectoryPath workspaceFolder, + DefinitionBase snapshot, + FileAttachmentComponent component, + IReadOnlyDictionary baseline, + ConcurrentDictionary newHashes, + ConcurrentBag uploaded, + ISyncDataverseClient dataverseClient, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(component.DisplayName)) + { + return; + } + + var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(component, snapshot)); + var contentPath = GetKnowledgeContentFilePath(componentPath, component.DisplayName!); + if (!IsValidFileToUpload(fileAccessor, contentPath)) + { + return; + } + + var key = component.Id.Value.ToString("N"); + var hash = ComputeKnowledgeFileHash(fileAccessor, contentPath); + if (hash != null && baseline.TryGetValue(key, out var existing) && string.Equals(existing, hash, StringComparison.Ordinal)) + { + return; + } + + await dataverseClient.UploadKnowledgeFileAsync( + Path.Combine(workspaceFolder.ToString(), componentPath.ParentDirectoryName), + component.Id.Value, + component.DisplayName!, + cancellationToken).ConfigureAwait(false); + + if (hash != null) + { + newHashes[key] = hash; + } + + uploaded.Add(component.DisplayName!); + } + private async Task DownloadSingleKnowledgeFileAsync( DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, @@ -1226,154 +1254,912 @@ private List ScanForNewKnowledgeFiles(IFileAccessor fileAccess continue; } - if (!IsValidFileToUpload(fileAccessor, file)) - { - continue; - } + if (!IsValidFileToUpload(fileAccessor, file)) + { + continue; + } + + var schemaName = SchemaNameGenerator.GenerateSchemaNameForBotComponent( + botSchemaPrefix: GetSchemaName(definition), + componentPrefix: "file", + componentDisplayName: file.FileName, + existingSchemaNames: existingSchemaNames + ); + existingSchemaNames.Add(schemaName); + + var builder = new FileAttachmentComponent() + .WithSchemaName(schemaName) + .WithDisplayName(file.FileName) + .WithDescription($"This knowledge source searches information contained in {file.FileName}") + .ToBuilder(); + if (parentId.HasValue) + { + builder.ParentBotComponentId = parentId.Value; + } + + newComponents.Add(builder.Build()); + } + } + + return newComponents; + } + + private static string GetKnowledgeFilesFolder(DefinitionBase definition, BotComponentId? parentId) + { + var subPath = (definition is BotDefinition botDef && botDef.Entity != null && IsCliAgentEntity(botDef.Entity)) + ? CliKnowledgeFilesSubPath + : KnowledgeFilesSubPath; + + if (parentId.HasValue + && definition.TryGetBotComponentById(parentId.Value, out var parent) + && parent is DialogComponent dialogComponent + && dialogComponent.RootElement is AgentDialog) + { + var agentName = ExtractSubAgentName(dialogComponent.SchemaNameString ?? string.Empty); + return $"agents/{agentName}/{subPath}"; + } + + return subPath; + } + + private static string ExtractSubAgentName(string schemaName) + { + const string infix = ".agent."; + var infixIndex = schemaName.IndexOf(infix, StringComparison.OrdinalIgnoreCase); + if (infixIndex >= 0) + { + return schemaName.Substring(infixIndex + infix.Length); + } + + return string.IsNullOrWhiteSpace(schemaName) ? "Unknown" : schemaName; + } + + private void MaterializeNewKnowledgeFileMetadata(DirectoryPath workspaceFolder, DefinitionBase definition, IReadOnlyList newComponents, CancellationToken cancellationToken) + { + if (newComponents.Count == 0) + { + return; + } + + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + foreach (var newComponent in newComponents) + { + cancellationToken.ThrowIfCancellationRequested(); + var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(newComponent, definition)); + if (fileAccessor.Exists(componentPath)) + { + continue; + } + + try + { + using var stream = fileAccessor.OpenWrite(componentPath); + using var textWriter = new StreamWriter(stream); + CodeSerializer.SerializeAsMcsYml(textWriter, newComponent); + } + catch (IOException) when (fileAccessor.Exists(componentPath)) + { + } + } + } + + private static ImmutableArray FilterNewConnectionReferences(DefinitionBase currentDefinition, DefinitionBase? cloudCache) + { + var currentRefs = currentDefinition.ConnectionReferences; + if (currentRefs.IsDefaultOrEmpty) + { + return ImmutableArray.Empty; + } + + if (cloudCache == null) + { + return currentRefs; + } + + var cloudRefsByLogicalName = cloudCache.ConnectionReferences + .Where(cr => !string.IsNullOrWhiteSpace(cr.ConnectionReferenceLogicalName.Value)) + .ToLookup(cr => cr.ConnectionReferenceLogicalName.Value, StringComparer.OrdinalIgnoreCase); + + var newRefs = currentRefs + .Where(cr => !string.IsNullOrWhiteSpace(cr.ConnectionReferenceLogicalName.Value) && + !cloudRefsByLogicalName.Contains(cr.ConnectionReferenceLogicalName.Value)) + .ToImmutableArray(); + + return newRefs; + } + + /// + /// Returns connection reference the agent declares, annotated with the connection it is currently bound to in Dataverse (if any). + /// + public virtual async Task> GetAgentConnectionReferencesAsync(DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + { + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var effectiveDefinition = OverlayCliConnectionReferences(definition, fileAccessor, cancellationToken); + return await GetAgentConnectionReferencesAsync(effectiveDefinition, dataverseClient, cancellationToken).ConfigureAwait(false); + } + + /// + /// Returns only the connection references that are new to the cloud cache. + /// + /// Workspace folder used to read the on-disk overlay and cloud cache. + /// The bot definition declaring connection references. + /// Dataverse client. + /// Cancellation token. + /// The newly added connection references with their current binding state. + public virtual async Task> GetNewAgentConnectionReferencesAsync(DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + { + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var cloudCache = ReadCloudCacheSnapshot(fileAccessor, allowMissing: true); + var effectiveDefinition = OverlayCliConnectionReferences(definition, fileAccessor, cancellationToken); + + var newRefs = FilterNewConnectionReferences(effectiveDefinition, cloudCache); + if (newRefs.IsDefaultOrEmpty) + { + return Array.Empty(); + } + + var newDefinition = effectiveDefinition.WithConnectionReferences(newRefs); + return await GetAgentConnectionReferencesAsync(newDefinition, dataverseClient, cancellationToken).ConfigureAwait(false); + } + + public virtual async Task> GetAgentConnectionViewsAsync( + DirectoryPath workspaceFolder, + DefinitionBase definition, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient catalogClient, + PowerAppsContext catalogContext, + CancellationToken cancellationToken) + { + var viewFileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var declaredDefinition = definition.WithConnectionReferences(ReadDeclaredConnectionReferencesFromDisk(viewFileAccessor, definition)); + var references = await GetAgentConnectionReferencesAsync(declaredDefinition, dataverseClient, cancellationToken).ConfigureAwait(false); + var scan = ScanConnectionReferenceUsages(workspaceFolder, references); + var declaredLogicalNames = new HashSet(references.Select(r => r.ConnectionReferenceLogicalName), StringComparer.OrdinalIgnoreCase); + var authoredLogicalNames = scan.AuthoredLogicalNames + .Concat(scan.Workflows.SelectMany(w => w.ConnectionReferenceLogicalNames)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase); + var undeclared = authoredLogicalNames.Where(name => !declaredLogicalNames.Contains(name)).Select(name => new { LogicalName = name, ConnectorName = ParseConnectorInternalId(name) }).ToList(); + + if (references.Count == 0 && undeclared.Count == 0) + { + return Array.Empty(); + } + + var candidatesByConnector = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + var catalogAttempted = catalogClient != null && !string.IsNullOrWhiteSpace(catalogContext?.AccessToken); + if (catalogAttempted) + { + var distinctConnectors = references.Select(r => r.ConnectorName).Concat(undeclared.Select(u => u.ConnectorName)).Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n!).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + using var throttler = new SemaphoreSlim(MaxConnectionCatalogConcurrency); + var listTasks = distinctConnectors.Select(async connectorName => + { + await throttler.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var connections = await catalogClient!.ListConnectionsAsync(catalogContext!, connectorName, cancellationToken).ConfigureAwait(false); + candidatesByConnector[connectorName] = connections.ToImmutableArray(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _syncProgress.Report($"Could not list connections for connector '{connectorName}': {ex.Message}"); + } + finally + { + throttler.Release(); + } + }); + + await Task.WhenAll(listTasks).ConfigureAwait(false); + } + + var result = new List(references.Count + undeclared.Count); + foreach (var reference in references) + { + var catalogKnown = candidatesByConnector.TryGetValue(reference.ConnectorName, out var found); + var candidates = catalogKnown ? found : ImmutableArray.Empty; + var boundExists = !string.IsNullOrWhiteSpace(reference.BoundConnectionId) && (!catalogKnown || candidates.Any(c => string.Equals(c.Name, reference.BoundConnectionId, StringComparison.OrdinalIgnoreCase))); + + result.Add(new AgentConnectionView + { + ConnectionReferenceLogicalName = reference.ConnectionReferenceLogicalName, + ConnectorId = reference.ConnectorId, + ConnectorName = reference.ConnectorName, + BoundConnectionId = reference.BoundConnectionId, + BoundConnectionExists = boundExists, + Candidates = candidates, + Usages = scan.GetUsages(reference.ConnectionReferenceLogicalName), + IsDeclared = true, + CatalogUnavailable = catalogAttempted && !string.IsNullOrWhiteSpace(reference.ConnectorName) && !catalogKnown, + }); + } + + foreach (var item in undeclared) + { + var catalogKnown = item.ConnectorName != null && candidatesByConnector.ContainsKey(item.ConnectorName); + var candidates = catalogKnown ? candidatesByConnector[item.ConnectorName!] : ImmutableArray.Empty; + + result.Add(new AgentConnectionView + { + ConnectionReferenceLogicalName = item.LogicalName, + ConnectorId = string.Empty, + ConnectorName = item.ConnectorName ?? string.Empty, + BoundConnectionId = string.Empty, + BoundConnectionExists = false, + Candidates = candidates, + Usages = scan.GetUsages(item.LogicalName), + IsDeclared = false, + CatalogUnavailable = catalogAttempted && !string.IsNullOrWhiteSpace(item.ConnectorName) && !catalogKnown, + }); + } + + return result; + } + + private static string? ParseConnectorInternalId(string connectionReferenceLogicalName) + { + if (string.IsNullOrWhiteSpace(connectionReferenceLogicalName)) + { + return null; + } + + foreach (var segment in connectionReferenceLogicalName.Split('.')) + { + if (segment.StartsWith("shared_", StringComparison.OrdinalIgnoreCase)) + { + return segment; + } + } + + return null; + } + + public virtual ConnectionReferenceUsageScan ScanConnectionReferenceUsages(DirectoryPath workspaceFolder, IReadOnlyList references) + { + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var connectorInternalIdByLogicalName = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (references != null) + { + foreach (var reference in references) + { + if (!string.IsNullOrWhiteSpace(reference.ConnectionReferenceLogicalName) && !string.IsNullOrWhiteSpace(reference.ConnectorName)) + { + connectorInternalIdByLogicalName[reference.ConnectionReferenceLogicalName] = reference.ConnectorName; + } + } + } + + var scanner = new ConnectionReferenceUsageScanner(); + return scanner.Scan(fileAccessor, connectorInternalIdByLogicalName, CancellationToken.None); + } + + public virtual IReadOnlyList GetWorkflowStatusViews(DirectoryPath workspaceFolder, IReadOnlyList views) + { + var scan = ScanConnectionReferenceUsages(workspaceFolder, Array.Empty()); + return BuildWorkflowStatusViews(scan, views); + } + + private static IReadOnlyList BuildWorkflowStatusViews(ConnectionReferenceUsageScan scan, IReadOnlyList views) + { + var boundByLogicalName = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (views != null) + { + foreach (var view in views) + { + boundByLogicalName[view.ConnectionReferenceLogicalName] = view.BoundConnectionExists; + } + } + + var result = new List(scan.Workflows.Length); + foreach (var workflow in scan.Workflows) + { + var canEnable = workflow.ConnectionReferenceLogicalNames.Length == 0 + || workflow.ConnectionReferenceLogicalNames.All(n => boundByLogicalName.TryGetValue(n, out var bound) && bound); + + result.Add(new WorkflowStatusView + { + WorkflowId = workflow.WorkflowId, + DisplayName = workflow.DisplayName, + FilePath = workflow.FilePath, + State = workflow.State, + ConnectionReferenceLogicalNames = workflow.ConnectionReferenceLogicalNames, + CanEnable = canEnable, + }); + } + + return result; + } + + public virtual async Task SetWorkflowActivationsAsync(DirectoryPath workspaceFolder, IReadOnlyList requests, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + { + var cache = ReadConnectionsCache(workspaceFolder); + var views = cache?.Connections ?? ImmutableArray.Empty; + var workflows = GetWorkflowStatusViews(workspaceFolder, views.ToList()); + + if (requests == null || requests.Count == 0) + { + return new WorkflowActivationResult { Succeeded = true, Workflows = workflows.ToImmutableArray() }; + } + + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var w in workflows) + { + byId[w.WorkflowId] = w; + } + + var applied = new Dictionary(); + string? firstError = null; + + foreach (var request in requests) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!byId.TryGetValue(request.WorkflowId.ToString(), out var target)) + { + firstError ??= $"Workflow '{request.WorkflowId}' was not found in this agent."; + continue; + } + + if (request.Activate && !target.CanEnable) + { + firstError ??= "All connection references used by this workflow must be bound to an existing connection before it can be enabled."; + continue; + } + + try + { + await dataverseClient.SetWorkflowStateAsync(request.WorkflowId, request.Activate, cancellationToken).ConfigureAwait(false); + UpdateWorkflowStateInMetadata(workspaceFolder, target.FilePath, request.Activate); + applied[request.WorkflowId] = request.Activate; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (request.Activate && IsWorkflowActivationConnectionError(ex)) + { + UpdateWorkflowStateInMetadata(workspaceFolder, target.FilePath, activate: false); + applied[request.WorkflowId] = false; + firstError ??= $"Workflow '{target.DisplayName}' was kept as a draft because its connection could not be used. Bind a valid connection you can access, then enable it again."; + } + } + + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + if (applied.Count > 0) + { + UpdateWorkflowStatesInCloudCache(fileAccessor, applied); + } + + var appliedByWorkflowId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in applied) + { + appliedByWorkflowId[entry.Key.ToString()] = entry.Value; + } + + var refreshed = workflows.Select(w => appliedByWorkflowId.TryGetValue(w.WorkflowId, out var activate) + ? new WorkflowStatusView + { + WorkflowId = w.WorkflowId, + DisplayName = w.DisplayName, + FilePath = w.FilePath, + State = activate ? WorkflowState.Activated : WorkflowState.Draft, + ConnectionReferenceLogicalNames = w.ConnectionReferenceLogicalNames, + CanEnable = w.CanEnable, + } + : w) + .ToImmutableArray(); + + WriteConnectionsCacheAndBump(workspaceFolder, fileAccessor, views.ToList(), refreshed); + + return new WorkflowActivationResult + { + Succeeded = firstError == null, + Message = firstError, + Workflows = refreshed, + }; + } + + private static bool IsWorkflowActivationConnectionError(Exception ex) + { + var current = ex; + while (current != null) + { + var message = current.Message; + if (!string.IsNullOrEmpty(message) + && (message.IndexOf("ConnectionAuthorizationFailed", StringComparison.OrdinalIgnoreCase) >= 0 + || message.IndexOf("cannot be used to activate this flow", StringComparison.OrdinalIgnoreCase) >= 0 + || message.IndexOf("this is not a valid connection", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return true; + } + + current = current.InnerException; + } + + return false; + } + + private void UpdateWorkflowStateInMetadata(DirectoryPath workspaceFolder, string? metadataFilePath, bool activate) + { + if (string.IsNullOrWhiteSpace(metadataFilePath)) + { + return; + } + + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var path = new AgentFilePath(metadataFilePath!); + if (!fileAccessor.Exists(path)) + { + return; + } + + string yaml; + try + { + using var readStream = fileAccessor.OpenRead(path); + using var reader = new StreamReader(readStream); + yaml = reader.ReadToEnd(); + } + catch (IOException) + { + return; + } + + var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).IgnoreUnmatchedProperties().Build(); + WorkflowMetadata? metadata; + try + { + metadata = deserializer.Deserialize(yaml); + } + catch (YamlDotNet.Core.YamlException) + { + return; + } + + if (metadata == null) + { + return; + } + + metadata.StateCode = activate ? 1 : 0; + metadata.StatusCode = activate ? 2 : 1; + + var serializer = new SerializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); + using var writeStream = fileAccessor.OpenWrite(path); + using var writer = new StreamWriter(writeStream, Encoding.UTF8); + serializer.Serialize(writer, metadata); + } + + private void UpdateWorkflowStatesInCloudCache(IFileAccessor fileAccessor, IReadOnlyDictionary activations) + { + var snapshot = ReadCloudCacheSnapshot(fileAccessor, allowMissing: true); + if (snapshot?.Flows == null) + { + return; + } + + var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).IgnoreUnmatchedProperties().Build(); + var rebuilt = ImmutableArray.CreateBuilder(snapshot.Flows.Length); + var changed = false; + + foreach (var flow in snapshot.Flows) + { + if (activations.TryGetValue(flow.WorkflowId.Value, out var activate)) + { + var metadataYaml = GetWorkflowMetadata(flow); + if (!string.IsNullOrEmpty(metadataYaml)) + { + WorkflowMetadata? metadata; + try + { + metadata = deserializer.Deserialize(metadataYaml); + } + catch (YamlDotNet.Core.YamlException) + { + metadata = null; + } + + if (metadata != null) + { + metadata.StateCode = activate ? 1 : 0; + metadata.StatusCode = activate ? 2 : 1; + metadata.ClientData = GetClientData(flow); + var (updatedFlow, _) = GetFlowDefinition(metadata); + rebuilt.Add(updatedFlow); + changed = true; + continue; + } + } + } + + rebuilt.Add(flow); + } + + if (changed) + { + WriteCloudCache(fileAccessor, snapshot.WithFlows(rebuilt.ToImmutable())); + } + } + + public virtual async Task DeclareConnectionReferencesAsync(DirectoryPath workspaceFolder, DefinitionBase definition, IReadOnlyList logicalNames, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + { + if (logicalNames == null || logicalNames.Count == 0) + { + return new DeclareConnectionReferencesResult(); + } + + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var declaredRefs = ReadDeclaredConnectionReferencesFromDisk(fileAccessor, definition); + var existing = new HashSet(declaredRefs.Select(c => c.ConnectionReferenceLogicalName.Value), StringComparer.OrdinalIgnoreCase); + var additions = ImmutableArray.CreateBuilder(); + var declared = ImmutableArray.CreateBuilder(); + var invalid = ImmutableArray.CreateBuilder(); + var prefixCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var logicalName in logicalNames) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(logicalName) || existing.Contains(logicalName)) + { + continue; + } + + var internalId = ParseConnectorInternalId(logicalName); + if (string.IsNullOrWhiteSpace(internalId)) + { + invalid.Add(logicalName); + continue; + } + + var rawConnectorId = $"/providers/Microsoft.PowerApps/apis/{internalId}"; + var connectorId = await ResolveTargetConnectorIdAsync(rawConnectorId, dataverseClient, cancellationToken, prefixCache).ConfigureAwait(false); + await dataverseClient.EnsureConnectionReferenceExistsAsync(logicalName, connectorId, cancellationToken).ConfigureAwait(false); + additions.Add(new ConnectionReference(connectionReferenceLogicalName: logicalName, connectionId: string.Empty, connectorId: connectorId)); + declared.Add(logicalName); + existing.Add(logicalName); + } + + if (additions.Count == 0) + { + return new DeclareConnectionReferencesResult { Invalid = invalid.ToImmutable() }; + } + + var updated = definition.WithConnectionReferences(declaredRefs.AddRange(additions.ToImmutable())); + var isCliAgent = definition is BotDefinition bot && bot.Entity != null && IsCliAgentEntity(bot.Entity); + await WriteConnectionReferencesAsync(fileAccessor, updated, isCliAgent, cancellationToken).ConfigureAwait(false); + + return new DeclareConnectionReferencesResult + { + Declared = declared.ToImmutable(), + Invalid = invalid.ToImmutable(), + }; + } + + public virtual async Task CreateConnectionReferenceForConnectorAsync(DirectoryPath workspaceFolder, DefinitionBase definition, string connectorInternalId, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(connectorInternalId)) + { + throw new ArgumentException("Connector internal id is required.", nameof(connectorInternalId)); + } + + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var declaredRefs = ReadDeclaredConnectionReferencesFromDisk(fileAccessor, definition); + var prefix = DeriveConnectionReferencePrefix(definition.WithConnectionReferences(declaredRefs)); + var logicalName = $"{prefix}.{connectorInternalId}.{Guid.NewGuid():N}"; + var connectorId = $"/providers/Microsoft.PowerApps/apis/{connectorInternalId}"; + + await dataverseClient.EnsureConnectionReferenceExistsAsync(logicalName, connectorId, cancellationToken).ConfigureAwait(false); + + var addition = new ConnectionReference(connectionReferenceLogicalName: logicalName, connectionId: string.Empty, connectorId: connectorId); + var updated = definition.WithConnectionReferences(declaredRefs.Add(addition)); + var isCliAgent = definition is BotDefinition bot && bot.Entity != null && IsCliAgentEntity(bot.Entity); + + await WriteConnectionReferencesAsync(fileAccessor, updated, isCliAgent, cancellationToken).ConfigureAwait(false); + + return logicalName; + } + + private static string DeriveConnectionReferencePrefix(DefinitionBase snapshot) + { + foreach (var reference in snapshot.ConnectionReferences) + { + var value = reference.ConnectionReferenceLogicalName.Value; + var firstDot = value.IndexOf('.'); + if (firstDot > 0) + { + return value.Substring(0, firstDot); + } + } + + if (snapshot is BotDefinition bot && bot.Entity != null) + { + var schemaName = bot.Entity.SchemaName.Value; + if (!string.IsNullOrWhiteSpace(schemaName)) + { + return schemaName; + } + } + + return "new_agent"; + } + + private ImmutableArray ReadDeclaredConnectionReferencesFromDisk(IFileAccessor fileAccessor, DefinitionBase definition) + { + var entity = (definition as BotDefinition)?.Entity; + if (entity != null && IsCliAgentEntity(entity) && CliAgentConnectionsReader.IsLayeredShapeActive(fileAccessor)) + { + var diskRefs = CliAgentConnectionsReader.Overlay(fileAccessor, Array.Empty(), _syncProgress.Report, CancellationToken.None); + return diskRefs.ToImmutableArray(); + } + + if (!fileAccessor.Exists(ConnectionReferencesPath)) + { + return ImmutableArray.Empty; + } + + string yaml; + try + { + using var stream = fileAccessor.OpenRead(ConnectionReferencesPath); + using var reader = new StreamReader(stream, Encoding.UTF8); + yaml = reader.ReadToEnd(); + } + catch (IOException) + { + return ImmutableArray.Empty; + } + + try + { + using var ctx = YamlSerializationContext.UseStandardSerializationContextIfNotDefined(throwOnInvalidYaml: false); + var parsed = CodeSerializer.Deserialize(yaml, sourceUri: null); + if (parsed == null || parsed.ConnectionReferences.IsDefaultOrEmpty) + { + return ImmutableArray.Empty; + } + + return parsed.ConnectionReferences; + } + catch (Exception) + { + return ImmutableArray.Empty; + } + } - var schemaName = SchemaNameGenerator.GenerateSchemaNameForBotComponent( - botSchemaPrefix: GetSchemaName(definition), - componentPrefix: "file", - componentDisplayName: file.FileName, - existingSchemaNames: existingSchemaNames - ); - existingSchemaNames.Add(schemaName); + public virtual async Task RemoveConnectionReferenceAsync(DirectoryPath workspaceFolder, DefinitionBase definition, string logicalName, bool confirmed, CancellationToken cancellationToken) + { + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var scan = ScanConnectionReferenceUsages(workspaceFolder, Array.Empty()); + var usages = scan.GetUsages(logicalName); - var builder = new FileAttachmentComponent() - .WithSchemaName(schemaName) - .WithDisplayName(file.FileName) - .WithDescription($"This knowledge source searches information contained in {file.FileName}") - .ToBuilder(); - if (parentId.HasValue) - { - builder.ParentBotComponentId = parentId.Value; - } + if (usages.Length > 0 && !confirmed) + { + return new ConnectionReferenceRemovalResult { Removed = false, Usages = usages }; + } - newComponents.Add(builder.Build()); - } + var declaredRefs = ReadDeclaredConnectionReferencesFromDisk(fileAccessor, definition); + var remaining = declaredRefs.Where(c => !string.Equals(c.ConnectionReferenceLogicalName.Value, logicalName, StringComparison.OrdinalIgnoreCase)).ToImmutableArray(); + + if (remaining.Length == declaredRefs.Length) + { + return new ConnectionReferenceRemovalResult { Removed = false, Usages = usages }; } - return newComponents; + var updated = definition.WithConnectionReferences(remaining); + var isCliAgent = definition is BotDefinition bot && bot.Entity != null && IsCliAgentEntity(bot.Entity); + await WriteConnectionReferencesAsync(fileAccessor, updated, isCliAgent, cancellationToken).ConfigureAwait(false); + + return new ConnectionReferenceRemovalResult { Removed = true }; } - private static string GetKnowledgeFilesFolder(DefinitionBase definition, BotComponentId? parentId) + public virtual async Task> ApplyConnectionBindingsAsync( + DirectoryPath workspaceFolder, + DefinitionBase definition, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient catalogClient, + PowerAppsContext catalogContext, + IReadOnlyList bindings, + CancellationToken cancellationToken) { - var subPath = (definition is BotDefinition botDef && botDef.Entity != null && IsCliAgentEntity(botDef.Entity)) - ? CliKnowledgeFilesSubPath - : KnowledgeFilesSubPath; + await DeclareConnectionReferencesAsync(workspaceFolder, definition, bindings.Select(b => b.ConnectionReferenceLogicalName).ToList(), dataverseClient, cancellationToken).ConfigureAwait(false); - if (parentId.HasValue - && definition.TryGetBotComponentById(parentId.Value, out var parent) - && parent is DialogComponent dialogComponent - && dialogComponent.RootElement is AgentDialog) + var prefixCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + +#if NETSTANDARD2_0 + foreach (var binding in bindings) { - var agentName = ExtractSubAgentName(dialogComponent.SchemaNameString ?? string.Empty); - return $"agents/{agentName}/{subPath}"; + cancellationToken.ThrowIfCancellationRequested(); + await BindConnectionAsync(binding, dataverseClient, prefixCache, cancellationToken).ConfigureAwait(false); } +#else + await Parallel.ForEachAsync(bindings, new ParallelOptions + { + MaxDegreeOfParallelism = 5, + CancellationToken = cancellationToken + }, + async (binding, ct) => + { + await BindConnectionAsync(binding, dataverseClient, prefixCache, ct).ConfigureAwait(false); + }).ConfigureAwait(false); +#endif - return subPath; + var views = await GetAgentConnectionViewsAsync(workspaceFolder, definition, dataverseClient, catalogClient, catalogContext, cancellationToken).ConfigureAwait(false); + + var writeAccessor = _fileAccessorFactory.Create(workspaceFolder); + var workflows = GetWorkflowStatusViews(workspaceFolder, views); + WriteConnectionsCacheAndBump(workspaceFolder, writeAccessor, views, workflows); + + return views; } - private static string ExtractSubAgentName(string schemaName) + private static async Task BindConnectionAsync(ConnectionBindingRequest binding, ISyncDataverseClient dataverseClient, IDictionary prefixCache, CancellationToken cancellationToken) { - const string infix = ".agent."; - var infixIndex = schemaName.IndexOf(infix, StringComparison.OrdinalIgnoreCase); - if (infixIndex >= 0) + if (string.IsNullOrWhiteSpace(binding.ConnectionReferenceLogicalName) || string.IsNullOrWhiteSpace(binding.ConnectionId)) { - return schemaName.Substring(infixIndex + infix.Length); + return; } - return string.IsNullOrWhiteSpace(schemaName) ? "Unknown" : schemaName; + await EnsureConnectionReferenceExistsBeforeBindAsync(binding.ConnectionReferenceLogicalName, dataverseClient, prefixCache, cancellationToken).ConfigureAwait(false); + + await dataverseClient.BindConnectionReferenceAsync(binding.ConnectionReferenceLogicalName, binding.ConnectionId, cancellationToken, binding.ConnectionDisplayName).ConfigureAwait(false); } - private void MaterializeNewKnowledgeFileMetadata(DirectoryPath workspaceFolder, DefinitionBase definition, IReadOnlyList newComponents, CancellationToken cancellationToken) + private static async Task EnsureConnectionReferenceExistsBeforeBindAsync(string logicalName, ISyncDataverseClient dataverseClient, IDictionary prefixCache, CancellationToken cancellationToken) { - if (newComponents.Count == 0) + var internalId = ParseConnectorInternalId(logicalName); + if (string.IsNullOrWhiteSpace(internalId)) { return; } + var rawConnectorId = $"/providers/Microsoft.PowerApps/apis/{internalId}"; + var resolvedConnectorId = await ResolveTargetConnectorIdAsync(rawConnectorId, dataverseClient, cancellationToken, prefixCache).ConfigureAwait(false); + await dataverseClient.EnsureConnectionReferenceExistsAsync(logicalName, resolvedConnectorId, cancellationToken).ConfigureAwait(false); + } + + public virtual void WriteConnectionsCache(DirectoryPath workspaceFolder, IReadOnlyList views) + { var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); - foreach (var newComponent in newComponents) + var workflows = GetWorkflowStatusViews(workspaceFolder, views); + WriteConnectionsCacheAndBump(workspaceFolder, fileAccessor, views, workflows); + } + + public virtual long GetConnectionsCacheGeneration(DirectoryPath workspaceFolder) + { + lock (_connectionsCacheGate) { - cancellationToken.ThrowIfCancellationRequested(); - var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(newComponent, definition)); - if (fileAccessor.Exists(componentPath)) + return GetConnectionsCacheGenerationLocked(workspaceFolder); + } + } + + public virtual bool TryWriteConnectionsCache(DirectoryPath workspaceFolder, IReadOnlyList views, long expectedGeneration) + { + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var workflows = GetWorkflowStatusViews(workspaceFolder, views); + lock (_connectionsCacheGate) + { + if (GetConnectionsCacheGenerationLocked(workspaceFolder) != expectedGeneration) { - continue; + return false; } try { - using var stream = fileAccessor.OpenWrite(componentPath); - using var textWriter = new StreamWriter(stream); - CodeSerializer.SerializeAsMcsYml(textWriter, newComponent); + WriteConnectionsCacheToDisk(fileAccessor, views, workflows); } - catch (IOException) when (fileAccessor.Exists(componentPath)) + catch (IOException) { + return false; } + + _connectionsCacheGeneration[workspaceFolder.ToString()] = expectedGeneration + 1; + return true; } } - private static ImmutableArray FilterNewConnectionReferences(DefinitionBase currentDefinition, DefinitionBase? cloudCache) + public virtual ConnectionsCacheFile? ReadConnectionsCache(DirectoryPath workspaceFolder) { - var currentRefs = currentDefinition.ConnectionReferences; - if (currentRefs.IsDefaultOrEmpty) + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + return ReadConnectionsCache(fileAccessor); + } + + private void WriteConnectionsCacheAndBump(DirectoryPath workspaceFolder, IFileAccessor fileAccessor, IReadOnlyList views, IReadOnlyList? workflows) + { + lock (_connectionsCacheGate) { - return ImmutableArray.Empty; + WriteConnectionsCacheToDisk(fileAccessor, views, workflows); + _connectionsCacheGeneration[workspaceFolder.ToString()] = GetConnectionsCacheGenerationLocked(workspaceFolder) + 1; } + } - if (cloudCache == null) + private long GetConnectionsCacheGenerationLocked(DirectoryPath workspaceFolder) => _connectionsCacheGeneration.TryGetValue(workspaceFolder.ToString(), out var generation) ? generation : 0; + + private static void WriteConnectionsCacheToDisk(IFileAccessor fileAccessor, IReadOnlyList views, IReadOnlyList? workflows) + { + var cache = new ConnectionsCacheFile { - return currentRefs; - } + RefreshedAt = DateTimeOffset.UtcNow, + Connections = views.ToImmutableArray(), + Workflows = workflows?.ToImmutableArray() ?? ImmutableArray.Empty, + }; - var cloudRefsByLogicalName = cloudCache.ConnectionReferences - .Where(cr => !string.IsNullOrWhiteSpace(cr.ConnectionReferenceLogicalName.Value)) - .ToLookup(cr => cr.ConnectionReferenceLogicalName.Value, StringComparer.OrdinalIgnoreCase); + var tempPath = new AgentFilePath(ConnectionsCachePath.ToString() + "." + Guid.NewGuid().ToString("N") + ".tmp"); + using (var stream = fileAccessor.OpenWrite(tempPath)) + { + JsonSerializer.Serialize(stream, cache, ConnectionsCacheJsonOptions); + } - var newRefs = currentRefs - .Where(cr => !string.IsNullOrWhiteSpace(cr.ConnectionReferenceLogicalName.Value) && - !cloudRefsByLogicalName.Contains(cr.ConnectionReferenceLogicalName.Value)) - .ToImmutableArray(); + try + { + ReplaceWithRetry(fileAccessor, tempPath, ConnectionsCachePath); + } + catch (IOException) + { + try + { + fileAccessor.Delete(tempPath); + } + catch (IOException) + { + } - return newRefs; + throw; + } } - /// - /// Returns connection reference the agent declares, annotated with the connection it is currently bound to in Dataverse (if any). - /// - public virtual async Task> GetAgentConnectionReferencesAsync(DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + private static void ReplaceWithRetry(IFileAccessor fileAccessor, AgentFilePath sourcePath, AgentFilePath targetPath) { - var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); - var effectiveDefinition = OverlayCliConnectionReferences(definition, fileAccessor, cancellationToken); - return await GetAgentConnectionReferencesAsync(effectiveDefinition, dataverseClient, cancellationToken).ConfigureAwait(false); + const int maxAttempts = 3; + for (var attempt = 1; ; attempt++) + { + try + { + fileAccessor.Replace(sourcePath, targetPath); + return; + } + catch (IOException) when (attempt < maxAttempts) + { + Thread.Sleep(40 * attempt); + } + } } - /// - /// Returns only the connection references that are new to the cloud cache. - /// - /// Workspace folder used to read the on-disk overlay and cloud cache. - /// The bot definition declaring connection references. - /// Dataverse client. - /// Cancellation token. - /// The newly added connection references with their current binding state. - public virtual async Task> GetNewAgentConnectionReferencesAsync(DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + private static ConnectionsCacheFile? ReadConnectionsCache(IFileAccessor fileAccessor) { - var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); - var cloudCache = ReadCloudCacheSnapshot(fileAccessor, allowMissing: true); - var effectiveDefinition = OverlayCliConnectionReferences(definition, fileAccessor, cancellationToken); - - var newRefs = FilterNewConnectionReferences(effectiveDefinition, cloudCache); - if (newRefs.IsDefaultOrEmpty) + if (!fileAccessor.Exists(ConnectionsCachePath)) { - return Array.Empty(); + return null; } - var newDefinition = effectiveDefinition.WithConnectionReferences(newRefs); - return await GetAgentConnectionReferencesAsync(newDefinition, dataverseClient, cancellationToken).ConfigureAwait(false); + try + { + using var stream = fileAccessor.OpenRead(ConnectionsCachePath); + return JsonSerializer.Deserialize(stream, ConnectionsCacheJsonOptions); + } + catch (JsonException) + { + return null; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } } + private static readonly JsonSerializerOptions ConnectionsCacheJsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + }; + /// /// Returns connection reference declared by the bot definition, annotated with its current Dataverse binding state. /// @@ -1682,7 +2468,8 @@ private async Task UpdateWorkspaceDirectoryAsync( DefinitionBase definition, IReadOnlyList deletedComponents, CancellationToken cancellationToken, - DefinitionBase? pathGroundingDefinition = null) + DefinitionBase? pathGroundingDefinition = null, + bool overrideConnectionReferences = false) { var thisSchema = string.Empty; @@ -1715,6 +2502,11 @@ private async Task UpdateWorkspaceDirectoryAsync( // This is benign because the only thing we use these for is looking up ids. var updatedDefinition = definition.ApplyChanges(changeset); + if (overrideConnectionReferences) + { + updatedDefinition = OverrideConnectionReferencesWithUsed(updatedDefinition); + } + var groundingDefinition = pathGroundingDefinition != null ? pathGroundingDefinition.ApplyChanges(changeset) : updatedDefinition; @@ -1989,7 +2781,7 @@ internal static void WriteCloudCache(IFileAccessor fileAccessor, DefinitionBase // This will ensure our cloud cache reflects the actual cloud. This is simple because: // - the cloud cache has BotIds, so it's easy to apply the changeset. // - the user can't edit the cloud cache, so there's never any conflict resolution. - private (DefinitionBase newCache, ImmutableArray deletedComponents) UpdateCloudCache(IFileAccessor fileAccessor, PvaComponentChangeSet changeset, CloudFlowMetadata? cloudFlowMetadata = null, ImmutableArray aiPrompts = default, Guid? agentId = null, bool preserveExistingAIModelDefinitions = false) + private (DefinitionBase newCache, ImmutableArray deletedComponents) UpdateCloudCache(IFileAccessor fileAccessor, PvaComponentChangeSet changeset, CloudFlowMetadata? cloudFlowMetadata = null, ImmutableArray aiPrompts = default, Guid? agentId = null, bool overrideConnectionReferences = false, bool preserveExistingAIModelDefinitions = false) { var snapshot = ReadCloudCacheSnapshot(fileAccessor); if (snapshot == null) @@ -2038,6 +2830,11 @@ internal static void WriteCloudCache(IFileAccessor fileAccessor, DefinitionBase newSnapshot = newSnapshot.WithAIModelDefinitions(BuildAIModelDefinitions(aiPrompts)); } + if (overrideConnectionReferences) + { + newSnapshot = OverrideConnectionReferencesWithUsed(newSnapshot); + } + WriteCloudCache(fileAccessor, newSnapshot); return (newSnapshot, deletedComponents.ToImmutable()); } @@ -2098,6 +2895,14 @@ public async Task SaveSyncInfoAsync(DirectoryPath workspaceFolder, AgentSyncInfo await JsonSerializer.SerializeAsync(stream, syncInfo).ConfigureAwait(false); } + public virtual void ClearComponentSyncBaselines(DirectoryPath workspaceFolder) + { + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + fileAccessor.Delete(ConnectorsSyncStatePath); + fileAccessor.Delete(KnowledgeSyncStatePath); + fileAccessor.Delete(AiPromptsSyncStatePath); + } + /// /// Check if the sync info (mcs\conn.json) is available in the workspace folder. /// @@ -2789,7 +3594,7 @@ private static BotComponentBase NormalizeForMcsYml(BotComponentBase component) } } - public virtual async Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken) + public virtual async Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken, WorkflowActivationMode activationMode = WorkflowActivationMode.PreserveSavedState) { var cloudFlowDefinitions = new List(); var workflows = new List(); @@ -2814,6 +3619,7 @@ private static BotComponentBase NormalizeForMcsYml(BotComponentBase component) { var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); var workflowsToUpload = new List(); + var unchangedActivatedWorkflows = new List(); foreach (var workflowFolder in Directory.EnumerateDirectories(workflowsDir)) { @@ -2850,17 +3656,46 @@ private static BotComponentBase NormalizeForMcsYml(BotComponentBase component) var (cloudFlowDefinition, _) = GetFlowDefinition(metadata); cloudFlowDefinitions.Add(cloudFlowDefinition); - if (cachedWorkflowClientData.TryGetValue(workflowId.Value, out var cachedClientData) + if (activationMode != WorkflowActivationMode.ActivateWhenConnectionsBound + && cachedWorkflowClientData.TryGetValue(workflowId.Value, out var cachedClientData) && string.Equals(cachedClientData, NormalizeWorkflowClientData(clientDataJson), StringComparison.Ordinal) && cachedWorkflowMetadata.TryGetValue(workflowId.Value, out var cachedMetadata) && string.Equals(cachedMetadata, NormalizeWorkflowMetadata(metadata), StringComparison.Ordinal)) { + if (activationMode == WorkflowActivationMode.DraftWhenConnectionsUnbound + && metadata.StateCode == 1 + && metadata.ConnectionReferences.Any(n => !string.IsNullOrWhiteSpace(n))) + { + unchangedActivatedWorkflows.Add(metadata); + } + continue; } workflowsToUpload.Add(metadata); } + if (activationMode == WorkflowActivationMode.ActivateWhenConnectionsBound) + { + if (workflowsToUpload.Count > 0) + { + await ApplyConnectionAwareWorkflowStateAsync(workflowsToUpload, dataverseClient, cancellationToken).ConfigureAwait(false); + } + } + else if (activationMode == WorkflowActivationMode.DraftWhenConnectionsUnbound) + { + var draftCandidates = new List(workflowsToUpload); + draftCandidates.AddRange(unchangedActivatedWorkflows); + var downgraded = await DraftUnboundWorkflowActivationsAsync(draftCandidates, dataverseClient, cancellationToken).ConfigureAwait(false); + foreach (var workflow in downgraded) + { + if (unchangedActivatedWorkflows.Contains(workflow)) + { + workflowsToUpload.Add(workflow); + } + } + } + if (workflowsToUpload.Count > 0) { #if NETSTANDARD2_0 @@ -2886,14 +3721,105 @@ private static BotComponentBase NormalizeForMcsYml(BotComponentBase component) #endif } - connectionReferences = await GetConnectionReferenceFromLogicalNamesAsync(GetConnectionReferenceLogicalNamesFromFlows(workflows), dataverseClient, cancellationToken).ConfigureAwait(false); + connectionReferences = await GetConnectionReferenceFromLogicalNamesAsync(GetConnectionReferenceLogicalNamesFromFlows(workflows), dataverseClient, cancellationToken).ConfigureAwait(false); + + if (activationMode != WorkflowActivationMode.PreserveSavedState) + { + cloudFlowDefinitions = workflows.Select(w => GetFlowDefinition(w).Item1).ToList(); + } + } + + return (workflowResponseBuilder.ToImmutable(), new CloudFlowMetadata + { + Workflows = cloudFlowDefinitions.ToImmutableArray(), + ConnectionReferences = connectionReferences + }); + } + + private static async Task ApplyConnectionAwareWorkflowStateAsync( + List workflows, + ISyncDataverseClient dataverseClient, + CancellationToken cancellationToken) + { + var logicalNames = workflows + .SelectMany(w => w.ConnectionReferences) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var boundLogicalNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (logicalNames.Count > 0) + { + var references = await dataverseClient.GetConnectionReferencesByLogicalNamesAsync(logicalNames, cancellationToken).ConfigureAwait(false); + foreach (var reference in references) + { + if (!string.IsNullOrWhiteSpace(reference.ConnectionId)) + { + boundLogicalNames.Add(reference.ConnectionReferenceLogicalName); + } + } + } + + foreach (var workflow in workflows) + { + var allConnectionsBound = workflow.ConnectionReferences.All(boundLogicalNames.Contains); + if (allConnectionsBound) + { + workflow.StateCode = 1; + workflow.StatusCode = 2; + } + else + { + workflow.StateCode = 0; + workflow.StatusCode = 1; + } + } + } + + private static async Task> DraftUnboundWorkflowActivationsAsync( + List workflows, + ISyncDataverseClient dataverseClient, + CancellationToken cancellationToken) + { + var activating = workflows + .Where(w => w.StateCode == 1 && w.ConnectionReferences.Any(n => !string.IsNullOrWhiteSpace(n))) + .ToList(); + if (activating.Count == 0) + { + return Array.Empty(); + } + + var logicalNames = activating + .SelectMany(w => w.ConnectionReferences) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var boundLogicalNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var references = await dataverseClient.GetConnectionReferencesByLogicalNamesAsync(logicalNames, cancellationToken).ConfigureAwait(false); + foreach (var reference in references) + { + if (!string.IsNullOrWhiteSpace(reference.ConnectionId)) + { + boundLogicalNames.Add(reference.ConnectionReferenceLogicalName); + } } - return (workflowResponseBuilder.ToImmutable(), new CloudFlowMetadata + var downgraded = new List(); + foreach (var workflow in activating) { - Workflows = cloudFlowDefinitions.ToImmutableArray(), - ConnectionReferences = connectionReferences - }); + var allConnectionsBound = workflow.ConnectionReferences + .Where(n => !string.IsNullOrWhiteSpace(n)) + .All(boundLogicalNames.Contains); + if (!allConnectionsBound) + { + workflow.StateCode = 0; + workflow.StatusCode = 1; + downgraded.Add(workflow); + } + } + + return downgraded; } public async Task GetWorkflowsAsync(DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, AgentSyncInfo syncInfo, IFileAccessor fileAccessor, CancellationToken cancellationToken) @@ -2901,6 +3827,7 @@ public async Task GetWorkflowsAsync(DirectoryPath workspaceFo var cloudFlowDefinitions = new List(); var workflows = new List(); var connectionReferences = ImmutableArray.Empty; + var succeeded = true; try { @@ -3056,13 +3983,15 @@ public async Task GetWorkflowsAsync(DirectoryPath workspaceFo } catch (Exception ex) { + succeeded = false; _syncProgress.Report($"Failed to download workflows for agent {syncInfo.AgentId}. Exception: {ex.Message}"); } return new CloudFlowMetadata { Workflows = cloudFlowDefinitions.ToImmutableArray(), - ConnectionReferences = connectionReferences + ConnectionReferences = connectionReferences, + Succeeded = succeeded }; } @@ -3106,6 +4035,8 @@ public virtual async Task> GetAIPromptsAsync(Di Directory.CreateDirectory(promptsRoot); } + var downloadedHashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var prompt in remotePrompts) { cancellationToken.ThrowIfCancellationRequested(); @@ -3192,6 +4123,16 @@ public virtual async Task> GetAIPromptsAsync(Di } #endif fileAccessor.Replace(promptMetadataTempPath, promptMetadataPath); + + var metadataAbsPath = Path.Combine(folderPath, "metadata.yml"); + var promptJsonAbsPath = Path.Combine(folderPath, "prompt.json"); + var writtenYaml = File.Exists(metadataAbsPath) + ? await FileShim.ReadAllTextAsync(metadataAbsPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false) + : null; + var writtenJson = File.Exists(promptJsonAbsPath) + ? await FileShim.ReadAllTextAsync(promptJsonAbsPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false) + : null; + downloadedHashes[prompt.AIModelId.ToString("N")] = ComputeAiPromptHash(writtenYaml, writtenJson); } var remoteIds = remotePrompts @@ -3205,6 +4146,11 @@ public virtual async Task> GetAIPromptsAsync(Di Directory.Delete(existingFolder.Value, true); } } + + if (downloadedHashes.Count > 0) + { + WriteAiPromptSyncState(fileAccessor, downloadedHashes); + } } catch (Exception ex) { @@ -3224,6 +4170,11 @@ public virtual async Task> GetAIPromptsAsync(Di return (responses.ToImmutable(), prompts.ToImmutable()); } + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var baseline = ReadAiPromptSyncState(fileAccessor); + var updatedState = new Dictionary(baseline, StringComparer.OrdinalIgnoreCase); + var stateChanged = false; + foreach (var promptFolder in Directory.EnumerateDirectories(promptsDir)) { cancellationToken.ThrowIfCancellationRequested(); @@ -3242,6 +4193,14 @@ public virtual async Task> GetAIPromptsAsync(Di } var yamlText = await FileShim.ReadAllTextAsync(metadataFile, Encoding.UTF8, cancellationToken).ConfigureAwait(false); + var promptJsonText = File.Exists(promptJsonFile) + ? await FileShim.ReadAllTextAsync(promptJsonFile, Encoding.UTF8, cancellationToken).ConfigureAwait(false) + : null; + + var key = aiModelId.Value.ToString("N"); + var hash = ComputeAiPromptHash(yamlText, promptJsonText); + var unchanged = baseline.TryGetValue(key, out var baselineHash) && string.Equals(baselineHash, hash, StringComparison.Ordinal); + var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).IgnoreUnmatchedProperties().Build(); AIPromptMetadata metadata; try @@ -3256,9 +4215,8 @@ public virtual async Task> GetAIPromptsAsync(Di metadata.AIModelId = aiModelId.Value; - if (File.Exists(promptJsonFile)) + if (promptJsonText != null) { - var promptJsonText = await FileShim.ReadAllTextAsync(promptJsonFile, Encoding.UTF8, cancellationToken).ConfigureAwait(false); metadata.CustomConfiguration = BuildCustomConfigurationFromPromptJson(promptJsonText); var promptName = TryReadPromptName(promptJsonText); @@ -3268,12 +4226,20 @@ public virtual async Task> GetAIPromptsAsync(Di } } + if (unchanged) + { + prompts.Add(metadata); + continue; + } + var response = await dataverseClient.UpsertAIPromptAsync(agentId, metadata, cancellationToken).ConfigureAwait(false); responses.Add(response); if (string.IsNullOrEmpty(response.ErrorMessage)) { prompts.Add(metadata); + updatedState[key] = hash; + stateChanged = true; } else { @@ -3281,6 +4247,11 @@ public virtual async Task> GetAIPromptsAsync(Di } } + if (stateChanged) + { + WriteAiPromptSyncState(fileAccessor, updatedState); + } + return (responses.ToImmutable(), prompts.ToImmutable()); } @@ -4549,12 +5520,95 @@ private async Task> GetConnectionReferenceFr return references; } + private static DefinitionBase OverrideConnectionReferencesWithUsed(DefinitionBase definition) + { + if (definition.ConnectionReferences.IsDefaultOrEmpty) + { + return definition; + } + + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var component in definition.Components) + { + if (component.RootElement is null) + { + continue; + } + + var yaml = CodeSerializer.Serialize(component.RootElement); + foreach (var name in ConnectionReferenceText.ExtractConnectionReferenceNames(yaml)) + { + used.Add(name); + } + } + + if (!definition.Flows.IsDefaultOrEmpty) + { + foreach (var flow in definition.Flows) + { + foreach (var name in GetFlowConnectionReferenceLogicalNames(flow)) + { + used.Add(name); + } + } + } + + var kept = definition.ConnectionReferences.Where(cr => used.Contains(cr.ConnectionReferenceLogicalName.Value)).ToImmutableArray(); + + return kept.Length == definition.ConnectionReferences.Length ? definition : definition.WithConnectionReferences(kept); + } + + private static readonly IDeserializer WorkflowMetadataDeserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + private static IEnumerable GetFlowConnectionReferenceLogicalNames(CloudFlowDefinition? flow) + { + var metadataYaml = GetWorkflowMetadata(flow); + if (string.IsNullOrWhiteSpace(metadataYaml)) + { + return Array.Empty(); + } + + SyncDataverseClient.WorkflowMetadata? metadata; + try + { + metadata = WorkflowMetadataDeserializer.Deserialize(metadataYaml); + } + catch (YamlDotNet.Core.YamlException) + { + return Array.Empty(); + } + + if (metadata?.ConnectionReferences == null) + { + return Array.Empty(); + } + + return metadata.ConnectionReferences.Where(n => !string.IsNullOrWhiteSpace(n)); + } + private static ImmutableArray GetConnectionReferenceLogicalNamesFromFlows(IEnumerable workflows) { var set = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var workflow in workflows) { + if (workflow.ConnectionReferences.Count > 0) + { + foreach (var name in workflow.ConnectionReferences) + { + if (!string.IsNullOrWhiteSpace(name)) + { + set.Add(name); + } + } + + continue; + } + if (string.IsNullOrWhiteSpace(workflow.ClientData)) { continue; @@ -4574,25 +5628,35 @@ private static ImmutableArray GetConnectionReferenceLogicalNamesFromFlow return set.ToImmutableArray(); } - private static string GetClientData(CloudFlowDefinition? flow) + private static string GetRawClientData(CloudFlowDefinition? flow) { if (flow?.ExtensionData?.Properties.TryGetValue("clientdata", out var value) == true && value is StringDataValue s && !string.IsNullOrEmpty(s.Value)) { - try - { - // ns2.0 BCL's IsNullOrEmpty lacks NotNullWhen; ! is compile-time only. - using var doc = JsonDocument.Parse(s.Value!); - return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); - } - catch (JsonException) - { - return s.Value!; - } + return s.Value!; } return string.Empty; } + private static string GetClientData(CloudFlowDefinition? flow) + { + var raw = GetRawClientData(flow); + if (string.IsNullOrEmpty(raw)) + { + return string.Empty; + } + + try + { + using var doc = JsonDocument.Parse(raw); + return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); + } + catch (JsonException) + { + return raw; + } + } + private static string NormalizeWorkflowClientData(string? clientData) { if (string.IsNullOrWhiteSpace(clientData)) @@ -4671,6 +5735,7 @@ private static (CloudFlowDefinition, ImmutableArray) GetFlowDefinition(W outputType = ExtractWorkflowOutputType(root); workflowConnectionNames = ExtractConnectionReferenceLogicalNames(root); + workflow.ConnectionReferences = workflowConnectionNames.ToList(); } var cloudFlowDefinition = new CloudFlowDefinition( @@ -4892,54 +5957,6 @@ private static DataType MapFlowType(string jsonType) }; } - /// - /// Validates connection references - removes orphaned entries without workspace usage (after pull). - /// - private BotDefinition? ValidateConnectionReferences(DefinitionBase definition) - { - if (definition is not BotDefinition bot || bot.Entity == null) - { - return null; - } - - // Get set of all ConnectionReferenceLogicalNames actually used in workspace - var usedConnections = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Collect all connection reference usages from the definition - foreach (var connRef in definition.ConnectionReferences) - { - var name = connRef.ConnectionReferenceLogicalName.ToString(); - if (!string.IsNullOrEmpty(name)) - { - usedConnections.Add(name); - } - } - - // Filter definition.ConnectionReferences to only include those actually used - var validConnections = definition.ConnectionReferences - .Where(cr => usedConnections.Contains(cr.ConnectionReferenceLogicalName.ToString())) - .OrderBy(cr => cr.ConnectionReferenceLogicalName.ToString(), StringComparer.Ordinal) - .ToList(); - - if (validConnections.Count < definition.ConnectionReferences.Length) - { - var removed = definition.ConnectionReferences.Length - validConnections.Count; - _syncProgress.Report($"Removed {removed} orphaned connection reference(s) with no workspace usage"); - } - - return bot; - } - - /// - /// Merges connection references from updated definition into changeset. - /// This preserves cloud's authoritative data (version, IDs, etc.). - /// - private PvaComponentChangeSet MergeConnectionReferencesIntoChangeset(DefinitionBase updatedDefinition, PvaComponentChangeSet changeset) - { - // Connection references are already in the definition, no need to merge into entity - return changeset; - } - private Task WriteConnectionReferencesAsync(IFileAccessor fileAccessor, DefinitionBase definition, bool isCliAgent, CancellationToken cancellationToken) { var uniqueConnectionReferences = definition.ConnectionReferences @@ -4983,13 +6000,16 @@ private Task WriteConnectionReferencesAsync(IFileAccessor fileAccessor, Definiti CodeSerializer.SerializeConnectionReferences(sw, uniqueConnectionReferences); } + else if (fileAccessor.Exists(ConnectionReferencesPath)) + { + fileAccessor.Delete(ConnectionReferencesPath); + } return Task.CompletedTask; } private async Task WriteCustomConnectorsAsync(IFileAccessor fileAccessor, DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) { - var expectedConnectorIds = new HashSet(StringComparer.OrdinalIgnoreCase); var internalIdToConnectorId = new Dictionary(StringComparer.OrdinalIgnoreCase); if (!definition.ConnectionReferences.IsDefaultOrEmpty) @@ -5167,7 +6187,6 @@ private async Task WriteCustomConnectorsAsync(IFileAccessor fileAccessor, Direct } var metadataNode = System.Text.Json.Nodes.JsonNode.Parse(JsonSerializer.Serialize(connector, jsonOpts))!.AsObject(); - var connectorNameForRef = !string.IsNullOrWhiteSpace(connector.Name) ? connector.Name! : safeName; metadataNode["openapidefinition"] = swaggerWritten ? $"{folder}/openapidefinition.json" : null; metadataNode["connectionparameters"] = $"{folder}/connectionparameters.json"; metadataNode["policytemplateinstances"] = $"{folder}/policytemplateinstances.json"; @@ -5175,6 +6194,196 @@ private async Task WriteCustomConnectorsAsync(IFileAccessor fileAccessor, Direct await fileAccessor.WriteAsync(new AgentFilePath($"{folder}/metadata.yml"), metadataNode.ToJsonString(jsonOpts), cancellationToken).ConfigureAwait(false); } + + RecordConnectorSyncState(fileAccessor, connectors); + } + + private static void RecordConnectorSyncState(IFileAccessor fileAccessor, IReadOnlyList connectors) + { + if (connectors == null || connectors.Count == 0) + { + return; + } + + var state = ReadConnectorSyncState(fileAccessor); + var changed = false; + foreach (var connector in connectors) + { + if (connector.ConnectorId == Guid.Empty) + { + continue; + } + + var key = connector.ConnectorId.ToString("N"); + var hash = ComputeConnectorContentHash(connector); + if (!state.TryGetValue(key, out var existing) || !string.Equals(existing, hash, StringComparison.Ordinal)) + { + state[key] = hash; + changed = true; + } + } + + if (changed) + { + WriteConnectorSyncState(fileAccessor, state); + } + } + + private static Dictionary ReadConnectorSyncState(IFileAccessor fileAccessor) + => ReadSyncStateMap(fileAccessor, ConnectorsSyncStatePath); + + private static void WriteConnectorSyncState(IFileAccessor fileAccessor, IReadOnlyDictionary state) + => WriteSyncStateMap(fileAccessor, ConnectorsSyncStatePath, state); + + private static string ComputeConnectorContentHash(CustomConnectorMetadata connector) + { + var sb = new StringBuilder(); + sb.Append(connector.ConnectorId.ToString("N")); + AppendHashField(sb, connector.Name); + AppendHashField(sb, connector.DisplayName); + AppendHashField(sb, connector.Description); + AppendHashField(sb, connector.ConnectorInternalId); + AppendHashField(sb, connector.IconBrandColor); + AppendHashField(sb, connector.IconBlobBase64); + AppendHashField(sb, connector.ConnectorType?.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AppendHashField(sb, NormalizeJsonForHash(connector.OpenApiDefinition)); + AppendHashField(sb, NormalizeJsonForHash(connector.ConnectionParameters)); + AppendHashField(sb, NormalizeJsonForHash(connector.ConnectionParameterSets)); + AppendHashField(sb, NormalizeJsonForHash(connector.PolicyTemplateInstances)); + + return Sha256Base64(sb.ToString()); + } + + private static void AppendHashField(StringBuilder sb, string? value) + { + sb.Append('\u001f').Append(value ?? string.Empty); + } + + private static string NormalizeJsonForHash(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + try + { + using var doc = JsonDocument.Parse(value!); + return JsonSerializer.Serialize(doc.RootElement); + } + catch (JsonException) + { + return value!; + } + } + + private static Dictionary ReadKnowledgeSyncState(IFileAccessor fileAccessor) + => ReadSyncStateMap(fileAccessor, KnowledgeSyncStatePath); + + private static void WriteKnowledgeSyncState(IFileAccessor fileAccessor, IReadOnlyDictionary state) + => WriteSyncStateMap(fileAccessor, KnowledgeSyncStatePath, state); + + private static Dictionary ReadAiPromptSyncState(IFileAccessor fileAccessor) + => ReadSyncStateMap(fileAccessor, AiPromptsSyncStatePath); + + private static void WriteAiPromptSyncState(IFileAccessor fileAccessor, IReadOnlyDictionary state) + => WriteSyncStateMap(fileAccessor, AiPromptsSyncStatePath, state); + + private static string ComputeAiPromptHash(string? metadataYaml, string? promptJson) + { + return Sha256Base64((metadataYaml ?? string.Empty) + "\u001f" + (promptJson ?? string.Empty)); + } + + private static string Sha256Base64(string value) + { + using var sha = SHA256.Create(); + return Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(value))); + } + + private static Dictionary ReadSyncStateMap(IFileAccessor fileAccessor, AgentFilePath path) + { + try + { + if (!fileAccessor.Exists(path)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + using var stream = fileAccessor.OpenRead(path); + var parsed = JsonSerializer.Deserialize>(stream); + return parsed != null + ? new Dictionary(parsed, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + catch (Exception) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + private static void WriteSyncStateMap(IFileAccessor fileAccessor, AgentFilePath path, IReadOnlyDictionary state) + { + try + { + using var stream = fileAccessor.OpenWrite(path); + JsonSerializer.Serialize(stream, state, new JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception) + { + } + } + + private static string? ComputeKnowledgeFileHash(IFileAccessor fileAccessor, AgentFilePath knowledgeFile) + { + try + { + using var stream = fileAccessor.OpenRead(knowledgeFile); + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(stream); + return Convert.ToBase64String(bytes); + } + catch (Exception) + { + return null; + } + } + + private void RecordKnowledgeFilesBaseline(IFileAccessor fileAccessor, DefinitionBase snapshot, IEnumerable components) + { + var state = ReadKnowledgeSyncState(fileAccessor); + var changed = false; + foreach (var component in components) + { + if (string.IsNullOrEmpty(component.DisplayName)) + { + continue; + } + + var componentPath = new AgentFilePath(_pathResolver.GetComponentPath(component, snapshot)); + var contentPath = GetKnowledgeContentFilePath(componentPath, component.DisplayName!); + if (!fileAccessor.Exists(contentPath)) + { + continue; + } + + var hash = ComputeKnowledgeFileHash(fileAccessor, contentPath); + if (hash == null) + { + continue; + } + + var key = component.Id.Value.ToString("N"); + if (!state.TryGetValue(key, out var existing) || !string.Equals(existing, hash, StringComparison.Ordinal)) + { + state[key] = hash; + changed = true; + } + } + + if (changed) + { + WriteKnowledgeSyncState(fileAccessor, state); + } } public async Task PushCustomConnectorsAsync(DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) @@ -5219,10 +6428,28 @@ public async Task PushCustomConnectorsAsync(Directory return new CustomConnectorPushResult { PushedRowIds = pushedRowIds }; } + var fileAccessor = _fileAccessorFactory.Create(workspaceFolder); + var baseline = ReadConnectorSyncState(fileAccessor); + var updatedState = new Dictionary(baseline, StringComparer.OrdinalIgnoreCase); + var stateChanged = false; + foreach (var local in localConnectors) { cancellationToken.ThrowIfCancellationRequested(); + var key = local.ConnectorId.ToString("N"); + var hash = ComputeConnectorContentHash(local); + + if (baseline.TryGetValue(key, out var baselineHash) && string.Equals(baselineHash, hash, StringComparison.Ordinal)) + { + if (!string.IsNullOrWhiteSpace(local.ConnectorInternalId)) + { + pushedRowIds[local.ConnectorInternalId!] = local.ConnectorId; + } + + continue; + } + try { await dataverseClient.UpsertConnectorAsync(local, cancellationToken).ConfigureAwait(false); @@ -5230,6 +6457,9 @@ public async Task PushCustomConnectorsAsync(Directory { pushedRowIds[local.ConnectorInternalId!] = local.ConnectorId; } + + updatedState[key] = hash; + stateChanged = true; } catch (OperationCanceledException) { @@ -5242,6 +6472,11 @@ public async Task PushCustomConnectorsAsync(Directory } } + if (stateChanged) + { + WriteConnectorSyncState(fileAccessor, updatedState); + } + return new CustomConnectorPushResult { PushedRowIds = pushedRowIds }; } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Completion/ConnectionReferenceCompletionRule.cs b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Completion/ConnectionReferenceCompletionRule.cs new file mode 100644 index 0000000..f231e74 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Completion/ConnectionReferenceCompletionRule.cs @@ -0,0 +1,135 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +namespace Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Completion +{ + using Microsoft.CopilotStudio.McsCore; + using Microsoft.PowerPlatformLS.Contracts.Internal.Completion; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Models; + using System; + using System.Collections.Generic; + using System.Text.RegularExpressions; + using Range = Microsoft.PowerPlatformLS.Contracts.Lsp.Models.Range; + + /// + /// IntelliSense completions for connectionReference values in agent component files. + /// + internal sealed class ConnectionReferenceCompletionRule : ICompletionRule + { + private const string DeclareCommand = "microsoft-copilot-studio.declareConnectionReference"; + private static readonly Regex ConnectionReferenceLineBeforeCursor = new(@"^[ \t]*connectionReference:[ \t]*[^\s#'""]*$", RegexOptions.Compiled); + private static readonly Regex ConnectionReferenceFullLine = new(@"^(?[ \t]*connectionReference:[ \t]*)(?[^\s#'""]*)", RegexOptions.Compiled); + private readonly IFileAccessorFactory _fileAccessorFactory; + + public IEnumerable? CharacterTriggers { get; } = [":", ".", " "]; + + public ConnectionReferenceCompletionRule(IFileAccessorFactory fileAccessorFactory) + { + _fileAccessorFactory = fileAccessorFactory ?? throw new ArgumentNullException(nameof(fileAccessorFactory)); + } + + public IEnumerable ComputeCompletion(RequestContext requestContext, CompletionContext triggerContext) + { + var document = requestContext.Document?.As(); + if (document == null) + { + yield break; + } + + var text = document.Text; + if (string.IsNullOrEmpty(text)) + { + yield break; + } + + var index = requestContext.Index; + if (index < 0 || index > text.Length) + { + yield break; + } + + var lineStart = text.LastIndexOf('\n', Math.Max(0, index - 1)) + 1; + var lineBeforeCursor = text.Substring(lineStart, index - lineStart); + if (!ConnectionReferenceLineBeforeCursor.IsMatch(lineBeforeCursor)) + { + yield break; + } + + var workspace = requestContext.Workspace; + if (workspace == null) + { + yield break; + } + + var entries = ConnectionsCacheReader.ReadConnections(_fileAccessorFactory, workspace.FolderPath); + if (entries == null) + { + yield break; + } + + foreach (var entry in entries) + { + var logicalName = entry.ConnectionReferenceLogicalName; + if (string.IsNullOrEmpty(logicalName)) + { + continue; + } + + var status = entry.BoundConnectionExists ? "bound" : entry.IsDeclared ? "not bound" : "not declared"; + var connector = string.IsNullOrEmpty(entry.ConnectorName) ? "connection" : entry.ConnectorName; + + var item = new CompletionItem + { + Label = logicalName!, + Kind = CompletionKind.Reference, + Detail = $"{connector} ({status})", + FilterText = logicalName, + InsertText = logicalName, + TextEdit = new TextEdit { Range = ComputeValueRange(text, lineStart, index), NewText = logicalName! }, + SortText = entry.IsDeclared ? "0" : "1", + }; + + if (!entry.IsDeclared) + { + item.Command = new LspCommand + { + Title = "Declare connection reference", + Command = DeclareCommand, + Arguments = new object[] { logicalName! }, + }; + } + + yield return item; + } + } + + private static Range ComputeValueRange(string text, int lineStart, int index) + { + var lineEnd = text.IndexOf('\n', index); + if (lineEnd < 0) + { + lineEnd = text.Length; + } + + var match = ConnectionReferenceFullLine.Match(text.Substring(lineStart, lineEnd - lineStart)); + var valueColumn = match.Success ? match.Groups["value"].Index : index - lineStart; + var valueLength = match.Success ? match.Groups["value"].Length : 0; + + var lineNumber = 0; + for (int i = 0; i < lineStart && i < text.Length; i++) + { + if (text[i] == '\n') + { + lineNumber++; + } + } + + return new Range + { + Start = new Position { Line = lineNumber, Character = valueColumn }, + End = new Position { Line = lineNumber, Character = valueColumn + valueLength }, + }; + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Connections/ConnectionsCacheReader.cs b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Connections/ConnectionsCacheReader.cs new file mode 100644 index 0000000..49a2a65 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Connections/ConnectionsCacheReader.cs @@ -0,0 +1,57 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +namespace Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio +{ + using Microsoft.CopilotStudio.McsCore; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text.Json; + + internal static class ConnectionsCacheReader + { + private static readonly AgentFilePath ConnectionsCachePath = new(".mcs/.connections-cache.json"); + private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); + + public static IReadOnlyList? ReadConnections(IFileAccessorFactory fileAccessorFactory, DirectoryPath folderPath) + { + try + { + var accessor = fileAccessorFactory.Create(folderPath); + if (!accessor.Exists(ConnectionsCachePath)) + { + return null; + } + + string json; + using (var stream = accessor.OpenRead(ConnectionsCachePath)) + using (var reader = new StreamReader(stream)) + { + json = reader.ReadToEnd(); + } + + return JsonSerializer.Deserialize(json, SerializerOptions)?.Connections; + } + catch (Exception) + { + return null; + } + } + + private sealed class CacheFileDto + { + public List? Connections { get; set; } + } + } + + internal sealed class ConnectionCacheEntry + { + public string? ConnectionReferenceLogicalName { get; set; } + + public string? ConnectorName { get; set; } + + public bool BoundConnectionExists { get; set; } + + public bool IsDeclared { get; set; } = true; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/DependencyInjection/McsLspModule.cs b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/DependencyInjection/McsLspModule.cs index 23815e2..c5c8280 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/DependencyInjection/McsLspModule.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/DependencyInjection/McsLspModule.cs @@ -4,7 +4,7 @@ using Microsoft.CopilotStudio.McsCore; using Microsoft.Agents.ObjectModel.Abstractions; using Microsoft.Extensions.DependencyInjection; - using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.PowerPlatformLS.Contracts.Internal; using Microsoft.PowerPlatformLS.Contracts.Internal.Common.DependencyInjection; using Microsoft.PowerPlatformLS.Contracts.Internal.Common.Framework; @@ -29,6 +29,8 @@ public void ConfigureServices(IServiceCollection services) services.AddValidationRulesProcessor(); services.AddSingleton(); services.AddSingleton, BotElementDiagnosticsValidationRule>(); + services.TryAddSingleton(); + services.AddSingleton, ConnectionReferenceValidationRule>(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton, DiagnosticsProvider>(); @@ -46,6 +48,7 @@ public void ConfigureServices(IServiceCollection services) AddMcsHandlers(services); services.AddCompletionRule(); + services.AddCompletionRule(); // Signature help, this is for Power Fx expressions services.AddHandler(); diff --git a/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Validation/ConnectionReferenceValidationRule.cs b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Validation/ConnectionReferenceValidationRule.cs new file mode 100644 index 0000000..c948ed0 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.Language.CopilotStudio/Validation/ConnectionReferenceValidationRule.cs @@ -0,0 +1,132 @@ +namespace Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Validation +{ + using Microsoft.CopilotStudio.McsCore; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Contracts.Internal.Validation; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Models; + using System; + using System.Collections.Generic; + using System.Text.RegularExpressions; + using Range = Microsoft.PowerPlatformLS.Contracts.Lsp.Models.Range; + + internal sealed class ConnectionReferenceValidationRule : IValidationRule + { + private const string UnknownConnectionReferenceCode = "UnknownConnectionReference"; + private const string UndeclaredConnectionReferenceCode = "UndeclaredConnectionReference"; + private const string UnboundConnectionReferenceCode = "UnboundConnectionReference"; + private static readonly Regex ConnectionReferenceLine = new(@"^[ \t]*connectionReference:[ \t]*(?:'(?[^']*)'|""(?[^""]*)""|(?[^\s#'\""]+))", RegexOptions.Compiled); + private readonly IFileAccessorFactory _fileAccessorFactory; + + public ConnectionReferenceValidationRule(IFileAccessorFactory fileAccessorFactory) + { + if (fileAccessorFactory == null) + { + throw new ArgumentNullException(nameof(fileAccessorFactory)); + } + + _fileAccessorFactory = fileAccessorFactory; + } + + IEnumerable IValidationRule.ComputeValidation(RequestContext context, McsLspDocument document) + { + if (document == null) + { + yield break; + } + + var workspace = context.Workspace; + if (workspace == null) + { + yield break; + } + + var catalog = ReadCatalog(workspace.FolderPath); + if (catalog == null) + { + yield break; + } + + var text = document.Text; + if (string.IsNullOrEmpty(text)) + { + yield break; + } + + var lines = text.Split('\n'); + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) + { + var match = ConnectionReferenceLine.Match(lines[lineIndex]); + if (!match.Success) + { + continue; + } + + var valueGroup = match.Groups["value"]; + var value = valueGroup.Value; + if (string.IsNullOrEmpty(value)) + { + continue; + } + + var range = new Range + { + Start = new Position { Line = lineIndex, Character = valueGroup.Index }, + End = new Position { Line = lineIndex, Character = valueGroup.Index + value.Length }, + }; + + if (!catalog.TryGetValue(value, out var entry)) + { + yield return new Diagnostic + { + Code = UnknownConnectionReferenceCode, + Range = range, + Severity = DiagnosticSeverity.Error, + Message = $"Connection reference '{value}' was not found among the agent's connections. Use Manage Connections to bind it to an existing connection or create a new one.", + }; + } + else if (!entry.IsDeclared) + { + yield return new Diagnostic + { + Code = UndeclaredConnectionReferenceCode, + Range = range, + Severity = DiagnosticSeverity.Error, + Message = $"Connection reference '{value}' is used but not declared in this agent. Use Manage Connections to declare and bind it before pushing.", + }; + } + else if (!entry.BoundConnectionExists) + { + yield return new Diagnostic + { + Code = UnboundConnectionReferenceCode, + Range = range, + Severity = DiagnosticSeverity.Warning, + Message = $"Connection reference '{value}' is not bound to an existing connection. Use Manage Connections to bind it to an existing connection or create a new one.", + }; + } + } + } + + private Dictionary? ReadCatalog(DirectoryPath folderPath) + { + var entries = ConnectionsCacheReader.ReadConnections(_fileAccessorFactory, folderPath); + if (entries == null) + { + return null; + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries) + { + var logicalName = entry?.ConnectionReferenceLogicalName; + if (!string.IsNullOrEmpty(logicalName)) + { + result[logicalName!] = entry!; + } + } + + return result; + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.Language.Yaml/DependencyInjection/YamlLspModule.cs b/src/LanguageServers/PowerPlatformLS/Impl.Language.Yaml/DependencyInjection/YamlLspModule.cs index d9a30b3..46d8320 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.Language.Yaml/DependencyInjection/YamlLspModule.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.Language.Yaml/DependencyInjection/YamlLspModule.cs @@ -1,6 +1,8 @@ namespace Microsoft.PowerPlatformLS.Impl.Language.Yaml.DependencyInjection { using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.DependencyInjection.Extensions; + using Microsoft.CopilotStudio.McsCore; using Microsoft.PowerPlatformLS.Contracts.Internal; using Microsoft.PowerPlatformLS.Contracts.Internal.Common.DependencyInjection; using Microsoft.PowerPlatformLS.Contracts.Internal.Common.Framework; @@ -34,7 +36,9 @@ private static void AddLspMethodHandlers(IServiceCollection services) private static void AddYamlValidationRules(IServiceCollection services) { + services.TryAddSingleton(); services.AddSingleton, UniqueIdsErrorRule>(); + services.AddSingleton, WorkflowStateValidationRule>(); } private static void AddYamlCompletionRules(IServiceCollection services) diff --git a/src/LanguageServers/PowerPlatformLS/Impl.Language.Yaml/Validation/WorkflowStateValidationRule.cs b/src/LanguageServers/PowerPlatformLS/Impl.Language.Yaml/Validation/WorkflowStateValidationRule.cs new file mode 100644 index 0000000..faf6244 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.Language.Yaml/Validation/WorkflowStateValidationRule.cs @@ -0,0 +1,126 @@ +namespace Microsoft.PowerPlatformLS.Impl.Language.Yaml.Validation +{ + using Microsoft.CopilotStudio.McsCore; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Contracts.Internal.Validation; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using Microsoft.PowerPlatformLS.Impl.Language.Yaml.Model; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text.Json; + using Range = Microsoft.PowerPlatformLS.Contracts.Lsp.Models.Range; + + internal sealed class WorkflowStateValidationRule : IValidationRule + { + private const string WorkflowNotEnabledCode = "WorkflowNotEnabled"; + private const int ActivatedState = 2; + private static readonly AgentFilePath ConnectionsCachePath = new(".mcs/.connections-cache.json"); + private static readonly JsonSerializerOptions CacheSerializerOptions = new(JsonSerializerDefaults.Web); + private readonly IFileAccessorFactory _fileAccessorFactory; + + public WorkflowStateValidationRule(IFileAccessorFactory fileAccessorFactory) + { + _fileAccessorFactory = fileAccessorFactory ?? throw new ArgumentNullException(nameof(fileAccessorFactory)); + } + + IEnumerable IValidationRule.ComputeValidation(RequestContext context, YamlLspDocument document) + { + if (document == null) + { + yield break; + } + + var normalizedPath = document.FilePath.ToString().Replace('\\', '/'); + if (!normalizedPath.EndsWith("/metadata.yml", StringComparison.OrdinalIgnoreCase) || normalizedPath.IndexOf("/workflows/", StringComparison.OrdinalIgnoreCase) < 0) + { + yield break; + } + + var workspace = context.Workspace; + if (workspace == null) + { + yield break; + } + + var workflow = FindWorkflow(workspace.FolderPath, normalizedPath); + if (workflow == null || workflow.State == ActivatedState) + { + yield break; + } + + var displayName = string.IsNullOrEmpty(workflow.DisplayName) ? "(unnamed)" : workflow.DisplayName; + yield return new Diagnostic + { + Code = WorkflowNotEnabledCode, + Range = new Range + { + Start = new Position { Line = 0, Character = 0 }, + End = new Position { Line = 0, Character = 0 }, + }, + Severity = DiagnosticSeverity.Warning, + Message = $"Workflow '{displayName}' is in Draft and will not run until enabled. Open Connection Manager to enable it.", + }; + } + + private WorkflowDto? FindWorkflow(DirectoryPath folderPath, string normalizedDocumentPath) + { + try + { + var accessor = _fileAccessorFactory.Create(folderPath); + if (!accessor.Exists(ConnectionsCachePath)) + { + return null; + } + + string json; + using (var stream = accessor.OpenRead(ConnectionsCachePath)) + using (var reader = new StreamReader(stream)) + { + json = reader.ReadToEnd(); + } + + var parsed = JsonSerializer.Deserialize(json, CacheSerializerOptions); + if (parsed?.Workflows == null) + { + return null; + } + + foreach (var workflow in parsed.Workflows) + { + var filePath = workflow?.FilePath; + if (string.IsNullOrEmpty(filePath)) + { + continue; + } + + var normalizedWorkflowPath = filePath!.Replace('\\', '/'); + if (normalizedDocumentPath.EndsWith(normalizedWorkflowPath, StringComparison.OrdinalIgnoreCase)) + { + return workflow; + } + } + + return null; + } + catch (Exception) + { + return null; + } + } + + private sealed class CacheFileDto + { + public List? Workflows { get; set; } + } + + private sealed class WorkflowDto + { + public string? DisplayName { get; set; } + + public string? FilePath { get; set; } + + public int State { get; set; } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsHandler.cs new file mode 100644 index 0000000..005270a --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsHandler.cs @@ -0,0 +1,111 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.PowerPlatformLS.Contracts.Internal; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using System; + using System.Collections.Immutable; + using System.Threading; + using System.Threading.Tasks; + + [LanguageServerEndpoint(ApplyConnectionBindingsRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class ApplyConnectionBindingsHandler : IRequestHandler + { + private readonly IIslandControlPlaneService _islandControlPlaneService; + private readonly IConnectionManagementService _connectionManagementService; + private readonly ITokenManager _dataverseTokenManager; + private readonly ISyncDataverseClient _dataverseClient; + private readonly IConnectionCatalogClient _connectionCatalogClient; + private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; + private readonly IDiagnosticsPublisher _diagnosticsPublisher; + private readonly ILspLogger _logger; + + public bool MutatesSolutionState => true; + + public ApplyConnectionBindingsHandler( + IIslandControlPlaneService islandControlPlaneService, + IConnectionManagementService connectionManagementService, + ITokenManager dataverseTokenManager, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient connectionCatalogClient, + LspDataverseHttpClientAccessor dataverseHttpClientAccessor, + IDiagnosticsPublisher diagnosticsPublisher, + ILspLogger logger) + { + _islandControlPlaneService = islandControlPlaneService; + _connectionManagementService = connectionManagementService ?? throw new ArgumentNullException(nameof(connectionManagementService)); + _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); + _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _connectionCatalogClient = connectionCatalogClient ?? throw new ArgumentNullException(nameof(connectionCatalogClient)); + _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); + _diagnosticsPublisher = diagnosticsPublisher ?? throw new ArgumentNullException(nameof(diagnosticsPublisher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleRequestAsync(ApplyConnectionBindingsRequest request, RequestContext context, CancellationToken cancellationToken) + { + try + { + ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); + var workspace = (IMcsWorkspace)context.Workspace; + var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); + + if (!classification.Allows(SyncOperation.Push)) + { + return new ApplyConnectionBindingsResponse() + { + Code = 400, + Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), + }; + } + + var catalogContext = ConnectionHelper.BuildCatalogContext(request, request.ConnectionsAccessToken); + var views = await _connectionManagementService.ApplyConnectionBindingsAsync( + workspace.FolderPath, + workspace.Definition, + _dataverseClient, + _connectionCatalogClient, + catalogContext, + request.Bindings, + cancellationToken); + + await PublishConnectionDiagnosticsAsync(context, cancellationToken); + + return new ApplyConnectionBindingsResponse() + { + Code = 200, + Message = string.Empty, + AgentConnections = views.ToImmutableArray(), + }; + } + catch (DataverseBadRequestException ex) + { + _logger.LogException(ex); + return new ApplyConnectionBindingsResponse() { Code = ex.StatusCode, Message = ex.Message }; + } + catch (Exception ex) + { + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new ApplyConnectionBindingsResponse() { Code = code, Message = message }; + } + } + + private async Task PublishConnectionDiagnosticsAsync(RequestContext context, CancellationToken cancellationToken) + { + try + { + await _diagnosticsPublisher.PublishAllDiagnosticsAsync(context, cancellationToken); + } + catch (Exception ex) + { + _logger.LogException(ex); + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsRequest.cs new file mode 100644 index 0000000..8172345 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsRequest.cs @@ -0,0 +1,17 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class ApplyConnectionBindingsRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/applyConnectionBindings"; + + public required Uri WorkspaceUri { get; set; } + + public string? ConnectionsAccessToken { get; set; } + + public ImmutableArray Bindings { get; set; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsResponse.cs new file mode 100644 index 0000000..8e37c51 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ApplyConnectionBindingsResponse.cs @@ -0,0 +1,11 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class ApplyConnectionBindingsResponse : ResponseBase + { + public ImmutableArray AgentConnections { get; init; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ConnectionHelper.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ConnectionHelper.cs index 0431371..2640008 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ConnectionHelper.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ConnectionHelper.cs @@ -1,13 +1,11 @@ namespace Microsoft.PowerPlatformLS.Impl.PullAgent { using Microsoft.Agents.ObjectModel; - using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.CopilotStudio.McsCore; using Microsoft.CopilotStudio.Sync; using Microsoft.CopilotStudio.Sync.Dataverse; using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; using System; - using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; @@ -31,58 +29,17 @@ public static void ApplyConnectionContext(IIslandControlPlaneService islandContr AgentManagementEndpoint = new Uri(request.EnvironmentInfo.AgentManagementUrl) }; - public static async Task> ProvisionAndGetConnectionsAsync(IWorkspaceSynchronizer synchronizer, DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + public static PowerAppsContext BuildCatalogContext(DataverseRequest request, string? connectionsAccessToken) => new() { - var connectorPushResult = await synchronizer.PushCustomConnectorsAsync(workspaceFolder, dataverseClient, cancellationToken); - await synchronizer.ProvisionConnectionReferencesAsync(workspaceFolder, definition, dataverseClient, cancellationToken, connectorPushResult.PushedRowIds); - var agentConnections = await synchronizer.GetAgentConnectionReferencesAsync(workspaceFolder, definition, dataverseClient, cancellationToken); - return agentConnections.ToImmutableArray(); - } + AccessToken = connectionsAccessToken ?? string.Empty, + EnvironmentId = request.EnvironmentInfo.EnvironmentId, + ClusterCategory = request.AccountInfo.ClusterCategory, + }; - public static async Task> ProvisionAndGetNewConnectionsAsync(IWorkspaceSynchronizer synchronizer, DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + public static async Task ProvisionConnectionsAsync(IWorkspaceSynchronizer synchronizer, DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) { var connectorPushResult = await synchronizer.PushCustomConnectorsAsync(workspaceFolder, dataverseClient, cancellationToken); await synchronizer.ProvisionConnectionReferencesAsync(workspaceFolder, definition, dataverseClient, cancellationToken, connectorPushResult.PushedRowIds); - var agentConnections = await synchronizer.GetNewAgentConnectionReferencesAsync(workspaceFolder, definition, dataverseClient, cancellationToken); - return agentConnections.ToImmutableArray(); - } - - public static async Task BindConnectionsAsync(ISyncDataverseClient dataverseClient, ImmutableArray bindings, ILspLogger logger, CancellationToken cancellationToken) - { - if (bindings.IsDefaultOrEmpty) - { - return; - } - - foreach (var binding in bindings) - { - if (string.IsNullOrWhiteSpace(binding.ConnectionReferenceLogicalName) || string.IsNullOrWhiteSpace(binding.ConnectionLogicalName)) - { - continue; - } - - try - { - await dataverseClient.BindConnectionReferenceAsync(binding.ConnectionReferenceLogicalName, binding.ConnectionLogicalName, cancellationToken, binding.ConnectionDisplayName); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - logger.LogSensitiveInformation($"Failed to bind connection reference '{binding.ConnectionReferenceLogicalName}': {ex.Message}", "Failed to bind a connection reference."); - throw new ConnectionBindingException("Failed to bind a connection reference.", ex); - } - } - } - } - - internal sealed class ConnectionBindingException : InvalidOperationException - { - public ConnectionBindingException(string message, Exception innerException) - : base(message, innerException) - { } } } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceHandler.cs new file mode 100644 index 0000000..51d09dc --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceHandler.cs @@ -0,0 +1,102 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using System; + using System.Collections.Immutable; + using System.Threading; + using System.Threading.Tasks; + + [LanguageServerEndpoint(CreateConnectionReferenceRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class CreateConnectionReferenceHandler : IRequestHandler + { + private readonly IIslandControlPlaneService _islandControlPlaneService; + private readonly IConnectionManagementService _connectionManagementService; + private readonly ITokenManager _dataverseTokenManager; + private readonly ISyncDataverseClient _dataverseClient; + private readonly IConnectionCatalogClient _connectionCatalogClient; + private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; + private readonly ILspLogger _logger; + + public bool MutatesSolutionState => true; + + public CreateConnectionReferenceHandler( + IIslandControlPlaneService islandControlPlaneService, + IConnectionManagementService connectionManagementService, + ITokenManager dataverseTokenManager, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient connectionCatalogClient, + LspDataverseHttpClientAccessor dataverseHttpClientAccessor, + ILspLogger logger) + { + _islandControlPlaneService = islandControlPlaneService; + _connectionManagementService = connectionManagementService ?? throw new ArgumentNullException(nameof(connectionManagementService)); + _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); + _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _connectionCatalogClient = connectionCatalogClient ?? throw new ArgumentNullException(nameof(connectionCatalogClient)); + _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleRequestAsync(CreateConnectionReferenceRequest request, RequestContext context, CancellationToken cancellationToken) + { + try + { + ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); + var workspace = (IMcsWorkspace)context.Workspace; + var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); + + if (!classification.Allows(SyncOperation.Push)) + { + return new CreateConnectionReferenceResponse() + { + Code = 400, + Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), + }; + } + + var logicalName = await _connectionManagementService.CreateConnectionReferenceForConnectorAsync( + workspace.FolderPath, + workspace.Definition, + request.ConnectorInternalId, + _dataverseClient, + cancellationToken); + + var catalogContext = ConnectionHelper.BuildCatalogContext(request, request.ConnectionsAccessToken); + var views = await _connectionManagementService.GetAgentConnectionViewsAsync( + workspace.FolderPath, + workspace.Definition, + _dataverseClient, + _connectionCatalogClient, + catalogContext, + cancellationToken); + + _connectionManagementService.WriteConnectionsCache(workspace.FolderPath, views); + + return new CreateConnectionReferenceResponse() + { + Code = 200, + Message = string.Empty, + LogicalName = logicalName, + AgentConnections = views.ToImmutableArray(), + }; + } + catch (DataverseBadRequestException ex) + { + _logger.LogException(ex); + return new CreateConnectionReferenceResponse() { Code = ex.StatusCode, Message = ex.Message }; + } + catch (Exception ex) + { + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new CreateConnectionReferenceResponse() { Code = code, Message = message }; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceRequest.cs new file mode 100644 index 0000000..62b2eee --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceRequest.cs @@ -0,0 +1,15 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + + internal class CreateConnectionReferenceRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/createConnectionReference"; + + public required Uri WorkspaceUri { get; set; } + + public required string ConnectorInternalId { get; set; } + + public string? ConnectionsAccessToken { get; set; } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceResponse.cs new file mode 100644 index 0000000..a01e3af --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/CreateConnectionReferenceResponse.cs @@ -0,0 +1,13 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class CreateConnectionReferenceResponse : ResponseBase + { + public string LogicalName { get; init; } = string.Empty; + + public ImmutableArray AgentConnections { get; init; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesHandler.cs new file mode 100644 index 0000000..86721a6 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesHandler.cs @@ -0,0 +1,103 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using System; + using System.Collections.Immutable; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + [LanguageServerEndpoint(DeclareConnectionReferencesRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class DeclareConnectionReferencesHandler : IRequestHandler + { + private readonly IIslandControlPlaneService _islandControlPlaneService; + private readonly IConnectionManagementService _connectionManagementService; + private readonly ITokenManager _dataverseTokenManager; + private readonly ISyncDataverseClient _dataverseClient; + private readonly IConnectionCatalogClient _connectionCatalogClient; + private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; + private readonly ILspLogger _logger; + + public bool MutatesSolutionState => true; + + public DeclareConnectionReferencesHandler( + IIslandControlPlaneService islandControlPlaneService, + IConnectionManagementService connectionManagementService, + ITokenManager dataverseTokenManager, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient connectionCatalogClient, + LspDataverseHttpClientAccessor dataverseHttpClientAccessor, + ILspLogger logger) + { + _islandControlPlaneService = islandControlPlaneService; + _connectionManagementService = connectionManagementService ?? throw new ArgumentNullException(nameof(connectionManagementService)); + _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); + _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _connectionCatalogClient = connectionCatalogClient ?? throw new ArgumentNullException(nameof(connectionCatalogClient)); + _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleRequestAsync(DeclareConnectionReferencesRequest request, RequestContext context, CancellationToken cancellationToken) + { + try + { + ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); + var workspace = (IMcsWorkspace)context.Workspace; + var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); + + if (!classification.Allows(SyncOperation.Push)) + { + return new DeclareConnectionReferencesResponse() + { + Code = 400, + Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), + }; + } + + var declareResult = await _connectionManagementService.DeclareConnectionReferencesAsync( + workspace.FolderPath, + workspace.Definition, + request.LogicalNames.ToList(), + _dataverseClient, + cancellationToken); + + var catalogContext = ConnectionHelper.BuildCatalogContext(request, request.ConnectionsAccessToken); + var views = await _connectionManagementService.GetAgentConnectionViewsAsync( + workspace.FolderPath, + workspace.Definition, + _dataverseClient, + _connectionCatalogClient, + catalogContext, + cancellationToken); + + _connectionManagementService.WriteConnectionsCache(workspace.FolderPath, views); + + return new DeclareConnectionReferencesResponse() + { + Code = 200, + Message = string.Empty, + AgentConnections = views.ToImmutableArray(), + InvalidLogicalNames = declareResult.Invalid, + }; + } + catch (DataverseBadRequestException ex) + { + _logger.LogException(ex); + return new DeclareConnectionReferencesResponse() { Code = ex.StatusCode, Message = ex.Message }; + } + catch (Exception ex) + { + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new DeclareConnectionReferencesResponse() { Code = code, Message = message }; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesRequest.cs new file mode 100644 index 0000000..9e59283 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesRequest.cs @@ -0,0 +1,16 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class DeclareConnectionReferencesRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/declareConnectionReferences"; + + public required Uri WorkspaceUri { get; set; } + + public ImmutableArray LogicalNames { get; set; } = ImmutableArray.Empty; + + public string? ConnectionsAccessToken { get; set; } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesResponse.cs new file mode 100644 index 0000000..ab3e4bf --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DeclareConnectionReferencesResponse.cs @@ -0,0 +1,13 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class DeclareConnectionReferencesResponse : ResponseBase + { + public ImmutableArray AgentConnections { get; init; } = ImmutableArray.Empty; + + public ImmutableArray InvalidLogicalNames { get; init; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DependencyInjection/PullAgentLspModule.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DependencyInjection/PullAgentLspModule.cs index 6ffd871..2402a7b 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DependencyInjection/PullAgentLspModule.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/DependencyInjection/PullAgentLspModule.cs @@ -12,6 +12,7 @@ using Microsoft.Agents.Platform.Content.Internal.Modules; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.PowerPlatformLS.Contracts.Internal.Common; @@ -81,7 +82,8 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); AddHttpClient(HttpClientNames.Dataverse); AddHttpClient(HttpClientNames.BotManagement); - + services.AddHttpClient(HttpClientNames.ConnectionCatalog); + services.AddSingleton(sp => new PowerAppsClient(sp.GetRequiredService().CreateClient(HttpClientNames.ConnectionCatalog), userAgent)); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -90,12 +92,17 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); void AddHttpClient(string name) where THandler : DelegatingHandler { services.AddHttpClient(name) diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsHandler.cs new file mode 100644 index 0000000..29f47d5 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsHandler.cs @@ -0,0 +1,95 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using System; + using System.Collections.Immutable; + using System.Threading; + using System.Threading.Tasks; + + [LanguageServerEndpoint(ListAgentConnectionsRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class ListAgentConnectionsHandler : IRequestHandler + { + private readonly IIslandControlPlaneService _islandControlPlaneService; + private readonly IConnectionManagementService _connectionManagementService; + private readonly ITokenManager _dataverseTokenManager; + private readonly ISyncDataverseClient _dataverseClient; + private readonly IConnectionCatalogClient _connectionCatalogClient; + private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; + private readonly ILspLogger _logger; + + public bool MutatesSolutionState => false; + + public ListAgentConnectionsHandler( + IIslandControlPlaneService islandControlPlaneService, + IConnectionManagementService connectionManagementService, + ITokenManager dataverseTokenManager, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient connectionCatalogClient, + LspDataverseHttpClientAccessor dataverseHttpClientAccessor, + ILspLogger logger) + { + _islandControlPlaneService = islandControlPlaneService; + _connectionManagementService = connectionManagementService ?? throw new ArgumentNullException(nameof(connectionManagementService)); + _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); + _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _connectionCatalogClient = connectionCatalogClient ?? throw new ArgumentNullException(nameof(connectionCatalogClient)); + _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleRequestAsync(ListAgentConnectionsRequest request, RequestContext context, CancellationToken cancellationToken) + { + try + { + ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); + var workspace = (IMcsWorkspace)context.Workspace; + var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); + + if (!classification.Allows(SyncOperation.Push)) + { + return new ListAgentConnectionsResponse() + { + Code = 400, + Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), + }; + } + + var catalogContext = ConnectionHelper.BuildCatalogContext(request, request.ConnectionsAccessToken); + var cacheGeneration = _connectionManagementService.GetConnectionsCacheGeneration(workspace.FolderPath); + var views = await _connectionManagementService.GetAgentConnectionViewsAsync( + workspace.FolderPath, + workspace.Definition, + _dataverseClient, + _connectionCatalogClient, + catalogContext, + cancellationToken); + + _connectionManagementService.TryWriteConnectionsCache(workspace.FolderPath, views, cacheGeneration); + + return new ListAgentConnectionsResponse() + { + Code = 200, + Message = string.Empty, + AgentConnections = views.ToImmutableArray(), + }; + } + catch (DataverseBadRequestException ex) + { + _logger.LogException(ex); + return new ListAgentConnectionsResponse() { Code = ex.StatusCode, Message = ex.Message }; + } + catch (Exception ex) + { + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new ListAgentConnectionsResponse() { Code = code, Message = message }; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsRequest.cs new file mode 100644 index 0000000..0a311ee --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsRequest.cs @@ -0,0 +1,13 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + + internal class ListAgentConnectionsRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/listAgentConnections"; + + public required Uri WorkspaceUri { get; set; } + + public string? ConnectionsAccessToken { get; set; } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsResponse.cs new file mode 100644 index 0000000..76b4330 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListAgentConnectionsResponse.cs @@ -0,0 +1,11 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class ListAgentConnectionsResponse : ResponseBase + { + public ImmutableArray AgentConnections { get; init; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsHandler.cs similarity index 63% rename from src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushHandler.cs rename to src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsHandler.cs index c82c091..38bcb4b 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushHandler.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsHandler.cs @@ -2,49 +2,47 @@ namespace Microsoft.PowerPlatformLS.Impl.PullAgent { using Microsoft.Agents.Platform.Content.Exceptions; using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; using Microsoft.CopilotStudio.Sync; using Microsoft.CopilotStudio.Sync.Dataverse; - using Microsoft.CopilotStudio.McsCore; using Microsoft.PowerPlatformLS.Contracts.FileLayout; using Microsoft.PowerPlatformLS.Contracts.Internal.Models; using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; using System; + using System.Collections.Immutable; + using System.Linq; using System.Threading; using System.Threading.Tasks; - /// - /// Prepares connections for a push of an already-connected agent: provisions custom connectors and connection - /// references, then returns the connection references the client must fulfill before . - /// - [LanguageServerEndpoint(PreparePushRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] - internal class PreparePushHandler : IRequestHandler + [LanguageServerEndpoint(ListConnectorsRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class ListConnectorsHandler : IRequestHandler { private readonly IIslandControlPlaneService _islandControlPlaneService; - private readonly IWorkspaceSynchronizer _workspaceSynchronizer; private readonly ITokenManager _dataverseTokenManager; private readonly ISyncDataverseClient _dataverseClient; + private readonly IConnectionCatalogClient _connectionCatalogClient; private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; private readonly ILspLogger _logger; - public bool MutatesSolutionState => true; + public bool MutatesSolutionState => false; - public PreparePushHandler( + public ListConnectorsHandler( IIslandControlPlaneService islandControlPlaneService, - IWorkspaceSynchronizer workspaceSynchronizer, ITokenManager dataverseTokenManager, ISyncDataverseClient dataverseClient, + IConnectionCatalogClient connectionCatalogClient, LspDataverseHttpClientAccessor dataverseHttpClientAccessor, ILspLogger logger) { _islandControlPlaneService = islandControlPlaneService; - _workspaceSynchronizer = workspaceSynchronizer ?? throw new ArgumentNullException(nameof(workspaceSynchronizer)); _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _connectionCatalogClient = connectionCatalogClient ?? throw new ArgumentNullException(nameof(connectionCatalogClient)); _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task HandleRequestAsync(PreparePushRequest request, RequestContext context, CancellationToken cancellationToken) + public async Task HandleRequestAsync(ListConnectorsRequest request, RequestContext context, CancellationToken cancellationToken) { try { @@ -54,31 +52,32 @@ public async Task HandleRequestAsync(PreparePushRequest req if (!classification.Allows(SyncOperation.Push)) { - return new PreparePushResponse() + return new ListConnectorsResponse() { Code = 400, Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), }; } - var agentConnections = await ConnectionHelper.ProvisionAndGetNewConnectionsAsync(_workspaceSynchronizer, workspace.FolderPath, workspace.Definition, _dataverseClient, cancellationToken); + var catalogContext = ConnectionHelper.BuildCatalogContext(request, request.ConnectionsAccessToken); + var connectors = await _connectionCatalogClient.ListConnectorsAsync(catalogContext, cancellationToken); - return new PreparePushResponse() + return new ListConnectorsResponse() { Code = 200, Message = string.Empty, - AgentConnections = agentConnections, + Connectors = connectors.ToImmutableArray(), }; } catch (DataverseBadRequestException ex) { _logger.LogException(ex); - return new PreparePushResponse() { Code = ex.StatusCode, Message = ex.Message }; + return new ListConnectorsResponse() { Code = ex.StatusCode, Message = ex.Message }; } catch (Exception ex) { - _logger.LogException(ex); - return new PreparePushResponse() { Code = 500, Message = ex.Message }; + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new ListConnectorsResponse() { Code = code, Message = message }; } } } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsRequest.cs new file mode 100644 index 0000000..9ad6d13 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsRequest.cs @@ -0,0 +1,13 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + + internal class ListConnectorsRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/listConnectors"; + + public required Uri WorkspaceUri { get; set; } + + public string? ConnectionsAccessToken { get; set; } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsResponse.cs similarity index 53% rename from src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushResponse.cs rename to src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsResponse.cs index 8a57fe6..b5104e9 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushResponse.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListConnectorsResponse.cs @@ -4,8 +4,8 @@ namespace Microsoft.PowerPlatformLS.Impl.PullAgent using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; using System.Collections.Immutable; - internal class PreparePushResponse : ResponseBase + internal class ListConnectorsResponse : ResponseBase { - public ImmutableArray AgentConnections { get; init; } = ImmutableArray.Empty; + public ImmutableArray Connectors { get; init; } = ImmutableArray.Empty; } } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusHandler.cs new file mode 100644 index 0000000..57061b0 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusHandler.cs @@ -0,0 +1,101 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using System; + using System.Collections.Generic; + using System.Collections.Immutable; + using System.Threading; + using System.Threading.Tasks; + + [LanguageServerEndpoint(ListWorkflowStatusRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class ListWorkflowStatusHandler : IRequestHandler + { + private readonly IIslandControlPlaneService _islandControlPlaneService; + private readonly IConnectionManagementService _connectionManagementService; + private readonly IWorkflowActivationService _workflowActivationService; + private readonly ITokenManager _dataverseTokenManager; + private readonly ISyncDataverseClient _dataverseClient; + private readonly IConnectionCatalogClient _connectionCatalogClient; + private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; + private readonly ILspLogger _logger; + + public bool MutatesSolutionState => false; + + public ListWorkflowStatusHandler( + IIslandControlPlaneService islandControlPlaneService, + IConnectionManagementService connectionManagementService, + IWorkflowActivationService workflowActivationService, + ITokenManager dataverseTokenManager, + ISyncDataverseClient dataverseClient, + IConnectionCatalogClient connectionCatalogClient, + LspDataverseHttpClientAccessor dataverseHttpClientAccessor, + ILspLogger logger) + { + _islandControlPlaneService = islandControlPlaneService; + _connectionManagementService = connectionManagementService ?? throw new ArgumentNullException(nameof(connectionManagementService)); + _workflowActivationService = workflowActivationService ?? throw new ArgumentNullException(nameof(workflowActivationService)); + _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); + _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _connectionCatalogClient = connectionCatalogClient ?? throw new ArgumentNullException(nameof(connectionCatalogClient)); + _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleRequestAsync(ListWorkflowStatusRequest request, RequestContext context, CancellationToken cancellationToken) + { + try + { + ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); + var workspace = (IMcsWorkspace)context.Workspace; + var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); + + if (!classification.Allows(SyncOperation.Push)) + { + return new ListWorkflowStatusResponse() + { + Code = 400, + Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), + }; + } + + var catalogContext = ConnectionHelper.BuildCatalogContext(request, request.ConnectionsAccessToken); + var cache = _connectionManagementService.ReadConnectionsCache(workspace.FolderPath); + IReadOnlyList views = cache != null + ? cache.Connections + : await _connectionManagementService.GetAgentConnectionViewsAsync( + workspace.FolderPath, + workspace.Definition, + _dataverseClient, + _connectionCatalogClient, + catalogContext, + cancellationToken); + + var workflows = _workflowActivationService.GetWorkflowStatusViews(workspace.FolderPath, views); + + return new ListWorkflowStatusResponse() + { + Code = 200, + Message = string.Empty, + Workflows = workflows.ToImmutableArray(), + }; + } + catch (DataverseBadRequestException ex) + { + _logger.LogException(ex); + return new ListWorkflowStatusResponse() { Code = ex.StatusCode, Message = ex.Message }; + } + catch (Exception ex) + { + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new ListWorkflowStatusResponse() { Code = code, Message = message }; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusRequest.cs new file mode 100644 index 0000000..8f5a47a --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusRequest.cs @@ -0,0 +1,13 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + + internal class ListWorkflowStatusRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/listWorkflowStatus"; + + public required Uri WorkspaceUri { get; set; } + + public string? ConnectionsAccessToken { get; set; } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusResponse.cs new file mode 100644 index 0000000..074feda --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ListWorkflowStatusResponse.cs @@ -0,0 +1,11 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class ListWorkflowStatusResponse : ResponseBase + { + public ImmutableArray Workflows { get; init; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/LspExceptionHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/LspExceptionHandler.cs index d7e9304..60e4997 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/LspExceptionHandler.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/LspExceptionHandler.cs @@ -34,10 +34,6 @@ public static (int Code, string Message) Handle(Exception ex, ILspLogger logger, DirectoryNotFoundException dnf => LogErrorMessage(logger, 400, dnf.Message), - // Connection binding failed during reattach (e.g., missing connector config). - ConnectionBindingException cbe => - LogErrorMessage(logger, 400, cbe.Message), - // User validation: caller explicitly threw to signal bad input. InvalidOperationException ioe => LogErrorMessage(logger, 400, ioe.Message), diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushRequest.cs deleted file mode 100644 index 5ef864b..0000000 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PreparePushRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Microsoft.PowerPlatformLS.Impl.PullAgent -{ - using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; - - internal class PreparePushRequest : DataverseRequest, IHasWorkspace - { - public const string MessageName = "powerplatformls/preparePush"; - - public required Uri WorkspaceUri { get; set; } - } -} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachHandler.cs deleted file mode 100644 index b67e69e..0000000 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachHandler.cs +++ /dev/null @@ -1,162 +0,0 @@ -namespace Microsoft.PowerPlatformLS.Impl.PullAgent -{ - using Microsoft.Agents.ObjectModel; - using Microsoft.Agents.Platform.Content.Exceptions; - using Microsoft.CommonLanguageServerProtocol.Framework; - using Microsoft.CopilotStudio.Sync; - using Microsoft.CopilotStudio.Sync.Dataverse; - using Microsoft.CopilotStudio.McsCore; - using Microsoft.PowerPlatformLS.Contracts.FileLayout; - using Microsoft.PowerPlatformLS.Contracts.Internal.Models; - using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; - using System; - using System.Threading; - using System.Threading.Tasks; - - /// - /// Prepares connections for a reattach: validates the agent directory, provisions the cloud agent (creating it when missing), provisions custom connectors and connection references, then returns the connection - /// references the client must fulfill before finalizing the reattach (). - /// - [LanguageServerEndpoint(PrepareReattachRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] - internal class PrepareReattachHandler : IRequestHandler - { - private readonly IIslandControlPlaneService _islandControlPlaneService; - private readonly IWorkspaceSynchronizer _workspaceSynchronizer; - private readonly ITokenManager _dataverseTokenManager; - private readonly ISyncDataverseClient _dataverseClient; - private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; - private readonly ILspLogger _logger; - - public bool MutatesSolutionState => true; - - public PrepareReattachHandler( - IIslandControlPlaneService islandControlPlaneService, - IWorkspaceSynchronizer workspaceSynchronizer, - ITokenManager dataverseTokenManager, - ISyncDataverseClient dataverseClient, - LspDataverseHttpClientAccessor dataverseHttpClientAccessor, - ILspLogger logger) - { - _islandControlPlaneService = islandControlPlaneService; - _workspaceSynchronizer = workspaceSynchronizer ?? throw new ArgumentNullException(nameof(workspaceSynchronizer)); - _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); - _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); - _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task HandleRequestAsync(PrepareReattachRequest request, RequestContext context, CancellationToken cancellationToken) - { - var defaultSyncInfo = ConnectionHelper.BuildDefaultSyncInfo(request); - - try - { - ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); - - var workspace = (IMcsWorkspace)context.Workspace; - var workspaceFolder = request.WorkspaceUri.ToDirectoryPath(); - bool isNewAgent = false; - var language = context.Language; - - if (!language.IsValidAgentDirectory(workspaceFolder, out _)) - { - return CreateErrorResponse(400, "Agent directory is not valid for reattach. Try opening root of the selected agent folder.", defaultSyncInfo); - } - - if (_workspaceSynchronizer.IsSyncInfoAvailable(workspaceFolder)) - { - return CreateErrorResponse(400, "This agent is already connected to a cloud instance.", defaultSyncInfo); - } - - string thisSchema = string.Empty; - string agentDisplayName = "ReattachAgent"; - - if (workspace.Definition is BotComponentCollectionDefinition collection) - { - thisSchema = collection.GetRootSchemaName(); - } - else if (workspace.Definition is BotDefinition bot) - { - thisSchema = bot.GetRootSchemaName(); - if (!string.IsNullOrEmpty(bot.Entity?.DisplayName)) - { - agentDisplayName = bot.Entity.DisplayName; - } - } - - if (!string.IsNullOrWhiteSpace(thisSchema) && !SchemaNameValidator.IsValid(thisSchema)) - { - return CreateErrorResponse(400, $"Invalid schema name '{thisSchema}'.", defaultSyncInfo); - } - - var classification = AgentClassifier.Classify(workspace.Definition, workspaceFolder.ToString()); - if (!classification.Allows(SyncOperation.Reattach)) - { - return CreateErrorResponse(400, AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Reattach), defaultSyncInfo); - } - - var agentId = await _dataverseClient.GetAgentIdBySchemaNameAsync(thisSchema, cancellationToken); - bool updateWorkspaceDirectory = false; - if (agentId == Guid.Empty) - { - var authoringShape = workspace.AuthoringShape; - if (authoringShape == AuthoringShape.Unknown) - { - authoringShape = AgentClassifier.DetectAuthoringShapeFromFolder(workspaceFolder.ToString()); - } - - var newAgent = await _dataverseClient.CreateNewAgentAsync(agentDisplayName, thisSchema, authoringShape, cancellationToken); - agentId = newAgent.AgentId; - isNewAgent = true; - - if (!string.IsNullOrWhiteSpace(thisSchema) && thisSchema != newAgent.SchemaName) - { - _logger.LogSensitiveInformation($"PrepareReattachInfo: Local schema name '{thisSchema}' is different with the new agent's schema name '{newAgent.SchemaName}'.", "PrepareReattachInfo: Local schema name differs from the new agent's schema name."); - } - - updateWorkspaceDirectory = thisSchema != newAgent.SchemaName; - } - - var syncInfo = new AgentSyncInfo() - { - AgentId = agentId, - DataverseEndpoint = new Uri(request.EnvironmentInfo.DataverseUrl), - EnvironmentId = request.EnvironmentInfo.EnvironmentId, - AccountInfo = request.AccountInfo, - SolutionVersions = request.SolutionVersions, - AgentManagementEndpoint = new Uri(request.EnvironmentInfo.AgentManagementUrl) - }; - - var agentConnections = await ConnectionHelper.ProvisionAndGetConnectionsAsync( - _workspaceSynchronizer, workspaceFolder, workspace.Definition, _dataverseClient, cancellationToken); - - return new PrepareReattachResponse() - { - Code = 200, - Message = string.Empty, - AgentSyncInfo = syncInfo, - IsNewAgent = isNewAgent, - UpdateWorkspaceDirectory = updateWorkspaceDirectory, - AgentConnections = agentConnections, - }; - } - catch (DataverseBadRequestException ex) - { - _logger.LogException(ex); - return CreateErrorResponse(ex.StatusCode, ex.Message, defaultSyncInfo); - } - catch (Exception ex) - { - _logger.LogException(ex); - return CreateErrorResponse(500, ex.Message, defaultSyncInfo); - } - } - - private static PrepareReattachResponse CreateErrorResponse(int code, string message, AgentSyncInfo defaultSyncInfo) => new() - { - Code = code, - Message = message, - AgentSyncInfo = defaultSyncInfo, - }; - } -} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachRequest.cs deleted file mode 100644 index e23e491..0000000 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Microsoft.PowerPlatformLS.Impl.PullAgent -{ - using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; - - internal class PrepareReattachRequest : DataverseRequest, IHasWorkspace - { - public const string MessageName = "powerplatformls/prepareReattach"; - - public required Uri WorkspaceUri { get; set; } - } -} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachResponse.cs deleted file mode 100644 index 9b1ddf7..0000000 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/PrepareReattachResponse.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Microsoft.PowerPlatformLS.Impl.PullAgent -{ - using Microsoft.CopilotStudio.Sync; - using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; - using System.Collections.Immutable; - - internal class PrepareReattachResponse : ResponseBase - { - public AgentSyncInfo? AgentSyncInfo { get; init; } - - public bool IsNewAgent { get; init; } = false; - - public bool UpdateWorkspaceDirectory { get; init; } = false; - - public ImmutableArray AgentConnections { get; init; } = ImmutableArray.Empty; - } -} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentHandler.cs index f2b23f9..fb71e04 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentHandler.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentHandler.cs @@ -1,6 +1,6 @@ namespace Microsoft.PowerPlatformLS.Impl.PullAgent { - using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.Agents.ObjectModel; using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.CopilotStudio.Sync; using Microsoft.CopilotStudio.Sync.Dataverse; @@ -13,8 +13,7 @@ namespace Microsoft.PowerPlatformLS.Impl.PullAgent using System.Threading.Tasks; /// - /// Binds the connections the client created after , then upserts workflows and AI prompts and syncs the workspace. - /// Binding runs before the workflow upsert so workflows that depend on those connections succeed. + /// Reattaches a local agent workspace to a cloud environment. /// [LanguageServerEndpoint(ReattachAgentRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] internal class ReattachAgentHandler : IRequestHandler @@ -57,16 +56,7 @@ public ReattachAgentHandler( public async Task HandleRequestAsync(ReattachAgentRequest request, RequestContext context, CancellationToken cancellationToken) { var workspaceFolder = request.WorkspaceUri.ToDirectoryPath(); - - var defaultSyncInfo = new AgentSyncInfo() - { - AgentId = Guid.Empty, - DataverseEndpoint = new Uri(request.EnvironmentInfo.DataverseUrl), - EnvironmentId = request.EnvironmentInfo.EnvironmentId, - AccountInfo = request.AccountInfo, - SolutionVersions = request.SolutionVersions, - AgentManagementEndpoint = new Uri(request.EnvironmentInfo.AgentManagementUrl) - }; + var defaultSyncInfo = ConnectionHelper.BuildDefaultSyncInfo(request); try { @@ -77,54 +67,83 @@ public async Task HandleRequestAsync(ReattachAgentRequest if (!language.IsValidAgentDirectory(workspaceFolder, out _)) { - return new ReattachAgentResponse() + return CreateErrorResponse(400, "Agent directory is not valid for reattach. Try opening root of the selected agent folder.", defaultSyncInfo); + } + + if (_workspaceSynchronizer.IsSyncInfoAvailable(workspaceFolder)) + { + return CreateErrorResponse(400, "This agent is already connected to a cloud instance.", defaultSyncInfo); + } + + string thisSchema = string.Empty; + string agentDisplayName = "ReattachAgent"; + + if (workspace.Definition is BotComponentCollectionDefinition collection) + { + thisSchema = collection.GetRootSchemaName(); + } + else if (workspace.Definition is BotDefinition bot) + { + thisSchema = bot.GetRootSchemaName(); + if (!string.IsNullOrEmpty(bot.Entity?.DisplayName)) { - Code = 400, - Message = "Agent directory is not valid for reattach. Try opening root of the selected agent folder.", - AgentSyncInfo = defaultSyncInfo - }; + agentDisplayName = bot.Entity!.DisplayName!; + } + } + + if (!string.IsNullOrWhiteSpace(thisSchema) && !SchemaNameValidator.IsValid(thisSchema)) + { + return CreateErrorResponse(400, $"Invalid schema name '{thisSchema}'.", defaultSyncInfo); } var classification = AgentClassifier.Classify(workspace.Definition, workspaceFolder.ToString()); if (!classification.Allows(SyncOperation.Reattach)) { - return new ReattachAgentResponse() - { - Code = 400, - Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Reattach), - AgentSyncInfo = defaultSyncInfo - }; + return CreateErrorResponse(400, AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Reattach), defaultSyncInfo); } - if (_workspaceSynchronizer.IsSyncInfoAvailable(workspaceFolder)) + var agentId = await _dataverseClient.GetAgentIdBySchemaNameAsync(thisSchema, cancellationToken); + bool isNewAgent = false; + bool updateWorkspaceDirectory = false; + if (agentId == Guid.Empty) { - return new ReattachAgentResponse() + var authoringShape = workspace.AuthoringShape; + if (authoringShape == AuthoringShape.Unknown) { - Code = 400, - Message = "This agent is already connected to a cloud instance.", - AgentSyncInfo = defaultSyncInfo - }; - } + authoringShape = AgentClassifier.DetectAuthoringShapeFromFolder(workspaceFolder.ToString()); + } - if (request.AgentSyncInfo is null) - { - return new ReattachAgentResponse() + var newAgent = await _dataverseClient.CreateNewAgentAsync(agentDisplayName, thisSchema, authoringShape, cancellationToken); + agentId = newAgent.AgentId; + isNewAgent = true; + + if (!string.IsNullOrWhiteSpace(thisSchema) && thisSchema != newAgent.SchemaName) { - Code = 400, - Message = "Reattach was not prepared for this agent. Run reattach again.", - AgentSyncInfo = defaultSyncInfo - }; + _logger.LogSensitiveInformation($"ReattachInfo: Local schema name '{thisSchema}' is different with the new agent's schema name '{newAgent.SchemaName}'.", "ReattachInfo: Local schema name differs from the new agent's schema name."); + } + + updateWorkspaceDirectory = thisSchema != newAgent.SchemaName; } - var syncInfo = request.AgentSyncInfo; - var agentId = syncInfo.AgentId; + var syncInfo = new AgentSyncInfo() + { + AgentId = agentId, + DataverseEndpoint = new Uri(request.EnvironmentInfo.DataverseUrl), + EnvironmentId = request.EnvironmentInfo.EnvironmentId, + AccountInfo = request.AccountInfo, + SolutionVersions = request.SolutionVersions, + AgentManagementEndpoint = new Uri(request.EnvironmentInfo.AgentManagementUrl) + }; + var operationContext = await _operationContextProvider.GetAsync(syncInfo); - await ConnectionHelper.BindConnectionsAsync(_dataverseClient, request.ConnectionBindings, _logger, cancellationToken); + _workspaceSynchronizer.ClearComponentSyncBaselines(workspaceFolder); + + await ConnectionHelper.ProvisionConnectionsAsync(_workspaceSynchronizer, workspaceFolder, workspace.Definition, _dataverseClient, cancellationToken); - var (workflowResponse, cloudFlowMetadata) = await _workspaceSynchronizer.UpsertWorkflowForAgentAsync(workspaceFolder, _dataverseClient, agentId, cancellationToken); + var (workflowResponse, cloudFlowMetadata) = await _workspaceSynchronizer.UpsertWorkflowForAgentAsync(workspaceFolder, _dataverseClient, agentId, cancellationToken, CopilotStudio.Sync.WorkflowActivationMode.ActivateWhenConnectionsBound); var (aiPromptResponse, _) = await _workspaceSynchronizer.UpsertAIPromptsForAgentAsync(workspaceFolder, _dataverseClient, agentId, cancellationToken); - await _workspaceSynchronizer.SyncWorkspaceAsync(workspaceFolder, operationContext, changeToken: null, request.UpdateWorkspaceDirectory, _dataverseClient, syncInfo, cloudFlowMetadata, cancellationToken: cancellationToken); + await _workspaceSynchronizer.SyncWorkspaceAsync(workspaceFolder, operationContext, changeToken: null, updateWorkspaceDirectory, _dataverseClient, syncInfo, cloudFlowMetadata, cancellationToken: cancellationToken); await _workspaceSynchronizer.SaveSyncInfoAsync(workspaceFolder, syncInfo); return new ReattachAgentResponse() @@ -132,7 +151,7 @@ public async Task HandleRequestAsync(ReattachAgentRequest Code = 200, Message = string.Empty, AgentSyncInfo = syncInfo, - IsNewAgent = request.IsNewAgent, + IsNewAgent = isNewAgent, WorkflowResponse = workflowResponse, AIPromptResponse = aiPromptResponse, }; @@ -140,13 +159,15 @@ public async Task HandleRequestAsync(ReattachAgentRequest catch (Exception ex) { var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); - return new ReattachAgentResponse() - { - Code = code, - Message = message, - AgentSyncInfo = defaultSyncInfo - }; + return CreateErrorResponse(code, message, defaultSyncInfo); } } + + private static ReattachAgentResponse CreateErrorResponse(int code, string message, AgentSyncInfo defaultSyncInfo) => new() + { + Code = code, + Message = message, + AgentSyncInfo = defaultSyncInfo, + }; } } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentRequest.cs index ff3b53b..160a2a5 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentRequest.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/ReattachAgentRequest.cs @@ -1,8 +1,6 @@ namespace Microsoft.PowerPlatformLS.Impl.PullAgent { - using Microsoft.CopilotStudio.Sync; using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; - using System.Collections.Immutable; /// /// ReattachAgentRequest is used to reattach the agent to a workspace. @@ -12,25 +10,5 @@ internal class ReattachAgentRequest : DataverseRequest, IHasWorkspace public const string MessageName = "powerplatformls/reattachAgent"; public required Uri WorkspaceUri { get; set; } - - public AgentSyncInfo? AgentSyncInfo { get; set; } - - public ImmutableArray ConnectionBindings { get; set; } = ImmutableArray.Empty; - - public bool IsNewAgent { get; set; } = false; - - public bool UpdateWorkspaceDirectory { get; set; } = false; - } - - /// - /// A connection reference logical name paired with the logical name of the connection to bind it to. - /// - internal class ConnectionBindingInput - { - public string ConnectionReferenceLogicalName { get; set; } = string.Empty; - - public string ConnectionLogicalName { get; set; } = string.Empty; - - public string? ConnectionDisplayName { get; set; } } } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceHandler.cs new file mode 100644 index 0000000..7062fc5 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceHandler.cs @@ -0,0 +1,87 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using System; + using System.Threading; + using System.Threading.Tasks; + + [LanguageServerEndpoint(RemoveConnectionReferenceRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class RemoveConnectionReferenceHandler : IRequestHandler + { + private readonly IIslandControlPlaneService _islandControlPlaneService; + private readonly IConnectionManagementService _connectionManagementService; + private readonly ITokenManager _dataverseTokenManager; + private readonly ISyncDataverseClient _dataverseClient; + private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; + private readonly ILspLogger _logger; + + public bool MutatesSolutionState => true; + + public RemoveConnectionReferenceHandler( + IIslandControlPlaneService islandControlPlaneService, + IConnectionManagementService connectionManagementService, + ITokenManager dataverseTokenManager, + ISyncDataverseClient dataverseClient, + LspDataverseHttpClientAccessor dataverseHttpClientAccessor, + ILspLogger logger) + { + _islandControlPlaneService = islandControlPlaneService; + _connectionManagementService = connectionManagementService ?? throw new ArgumentNullException(nameof(connectionManagementService)); + _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); + _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleRequestAsync(RemoveConnectionReferenceRequest request, RequestContext context, CancellationToken cancellationToken) + { + try + { + ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); + var workspace = (IMcsWorkspace)context.Workspace; + var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); + + if (!classification.Allows(SyncOperation.Push)) + { + return new RemoveConnectionReferenceResponse() + { + Code = 400, + Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), + }; + } + + var result = await _connectionManagementService.RemoveConnectionReferenceAsync( + workspace.FolderPath, + workspace.Definition, + request.LogicalName, + request.Confirmed, + cancellationToken); + + return new RemoveConnectionReferenceResponse() + { + Code = 200, + Message = string.Empty, + Removed = result.Removed, + Usages = result.Usages, + }; + } + catch (DataverseBadRequestException ex) + { + _logger.LogException(ex); + return new RemoveConnectionReferenceResponse() { Code = ex.StatusCode, Message = ex.Message }; + } + catch (Exception ex) + { + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new RemoveConnectionReferenceResponse() { Code = code, Message = message }; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceRequest.cs new file mode 100644 index 0000000..ae36846 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceRequest.cs @@ -0,0 +1,15 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + + internal class RemoveConnectionReferenceRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/removeConnectionReference"; + + public required Uri WorkspaceUri { get; set; } + + public required string LogicalName { get; set; } + + public bool Confirmed { get; set; } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceResponse.cs new file mode 100644 index 0000000..5782831 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/RemoveConnectionReferenceResponse.cs @@ -0,0 +1,13 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class RemoveConnectionReferenceResponse : ResponseBase + { + public bool Removed { get; init; } + + public ImmutableArray Usages { get; init; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesHandler.cs new file mode 100644 index 0000000..8c37694 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesHandler.cs @@ -0,0 +1,99 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.Agents.Platform.Content.Exceptions; + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.McsCore; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.FileLayout; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + [LanguageServerEndpoint(SetWorkflowStatesRequest.MessageName, LanguageServerConstants.DefaultLanguageName)] + internal class SetWorkflowStatesHandler : IRequestHandler + { + private readonly IIslandControlPlaneService _islandControlPlaneService; + private readonly IWorkflowActivationService _workflowActivationService; + private readonly ITokenManager _dataverseTokenManager; + private readonly ISyncDataverseClient _dataverseClient; + private readonly LspDataverseHttpClientAccessor _dataverseHttpClientAccessor; + private readonly ILspLogger _logger; + + public bool MutatesSolutionState => true; + + public SetWorkflowStatesHandler( + IIslandControlPlaneService islandControlPlaneService, + IWorkflowActivationService workflowActivationService, + ITokenManager dataverseTokenManager, + ISyncDataverseClient dataverseClient, + LspDataverseHttpClientAccessor dataverseHttpClientAccessor, + ILspLogger logger) + { + _islandControlPlaneService = islandControlPlaneService; + _workflowActivationService = workflowActivationService ?? throw new ArgumentNullException(nameof(workflowActivationService)); + _dataverseTokenManager = dataverseTokenManager ?? throw new ArgumentNullException(nameof(dataverseTokenManager)); + _dataverseClient = dataverseClient ?? throw new ArgumentNullException(nameof(dataverseClient)); + _dataverseHttpClientAccessor = dataverseHttpClientAccessor ?? throw new ArgumentNullException(nameof(dataverseHttpClientAccessor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task HandleRequestAsync(SetWorkflowStatesRequest request, RequestContext context, CancellationToken cancellationToken) + { + try + { + ConnectionHelper.ApplyConnectionContext(_islandControlPlaneService, _dataverseTokenManager, _dataverseHttpClientAccessor, _dataverseClient, request); + var workspace = (IMcsWorkspace)context.Workspace; + var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); + + if (!classification.Allows(SyncOperation.Push)) + { + return new SetWorkflowStatesResponse() + { + Code = 200, + Succeeded = false, + Message = AuthoringSupportGate.DescribeBlocked(classification, SyncOperation.Push), + }; + } + + var activationRequests = new List(request.Changes.Count); + foreach (var change in request.Changes) + { + if (!Guid.TryParse(change.WorkflowId, out var workflowId)) + { + return new SetWorkflowStatesResponse() { Code = 200, Succeeded = false, Message = $"Invalid workflow id '{change.WorkflowId}'." }; + } + + activationRequests.Add(new WorkflowActivationRequest { WorkflowId = workflowId, Activate = change.Activate }); + } + + var result = await _workflowActivationService.SetWorkflowActivationsAsync( + workspace.FolderPath, + activationRequests, + _dataverseClient, + cancellationToken); + + return new SetWorkflowStatesResponse() + { + Code = 200, + Message = result.Message ?? string.Empty, + Succeeded = result.Succeeded, + Workflows = result.Workflows, + }; + } + catch (DataverseBadRequestException ex) + { + _logger.LogException(ex); + return new SetWorkflowStatesResponse() { Code = ex.StatusCode, Message = ex.Message }; + } + catch (Exception ex) + { + var (code, message) = LspExceptionHandler.Handle(ex, _logger, cancellationToken); + return new SetWorkflowStatesResponse() { Code = code, Message = message }; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesRequest.cs new file mode 100644 index 0000000..7fe764b --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesRequest.cs @@ -0,0 +1,23 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Generic; + + internal class SetWorkflowStatesRequest : DataverseRequest, IHasWorkspace + { + public const string MessageName = "powerplatformls/setWorkflowStates"; + + public required Uri WorkspaceUri { get; set; } + + public IReadOnlyList Changes { get; set; } = new List(); + + public string? ConnectionsAccessToken { get; set; } + } + + internal class WorkflowStateChange + { + public string WorkflowId { get; set; } = string.Empty; + + public bool Activate { get; set; } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesResponse.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesResponse.cs new file mode 100644 index 0000000..1e6b9ad --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SetWorkflowStatesResponse.cs @@ -0,0 +1,13 @@ +namespace Microsoft.PowerPlatformLS.Impl.PullAgent +{ + using Microsoft.CopilotStudio.Sync; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using System.Collections.Immutable; + + internal class SetWorkflowStatesResponse : ResponseBase + { + public bool Succeeded { get; init; } + + public ImmutableArray Workflows { get; init; } = ImmutableArray.Empty; + } +} diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/Sync/HttpClientNames.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/Sync/HttpClientNames.cs index 1f1cf4e..c8e2fa9 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/Sync/HttpClientNames.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/Sync/HttpClientNames.cs @@ -5,5 +5,7 @@ internal static class HttpClientNames public static string BotManagement => nameof(BotManagement); public static string Dataverse => nameof(Dataverse); + + public static string ConnectionCatalog => nameof(ConnectionCatalog); } } \ No newline at end of file diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncAgentRequest.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncAgentRequest.cs index 1ad2525..defa3aa 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncAgentRequest.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncAgentRequest.cs @@ -1,12 +1,9 @@ namespace Microsoft.PowerPlatformLS.Impl.PullAgent { using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; - using System.Collections.Immutable; internal class SyncAgentRequest : DataverseRequest, IHasWorkspace { public required Uri WorkspaceUri { get; set; } - - public ImmutableArray ConnectionBindings { get; set; } = ImmutableArray.Empty; } } \ No newline at end of file diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncHandler.cs index 769ea6b..8d44ac2 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncHandler.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncHandler.cs @@ -59,7 +59,7 @@ public async Task HandleRequestAsync(SyncAgentRequest request var operationContext = await _operationContextProvider.GetAsync(syncInfo); - var (updatedDefinition, workflowResponse, aiPromptResponse) = await ExecuteAsync(workspace, operationContext, _dataverseClient, syncInfo, request.ConnectionBindings, cancellationToken); + var (updatedDefinition, workflowResponse, aiPromptResponse) = await ExecuteAsync(workspace, operationContext, _dataverseClient, syncInfo, cancellationToken); var (_, localChanges) = await _synchronizer.GetLocalChangesAsync(workspace.FolderPath, updatedDefinition, _dataverseClient, syncInfo, cancellationToken); return new SyncAgentResponse @@ -87,7 +87,6 @@ public async Task HandleRequestAsync(SyncAgentRequest request AuthoringOperationContextBase operationContext, ISyncDataverseClient dataverseClient, AgentSyncInfo syncInfo, - ImmutableArray connectionBindings, CancellationToken cancellationToken); } } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPullHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPullHandler.cs index 3f387bf..e9281dd 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPullHandler.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPullHandler.cs @@ -19,7 +19,7 @@ public SyncPullHandler(CopilotStudio.Sync.IIslandControlPlaneService islandContr { } - protected override async Task<(DefinitionBase, ImmutableArray, ImmutableArray)> ExecuteAsync(IMcsWorkspace workspace, AuthoringOperationContextBase operationContext, ISyncDataverseClient dataverseClient, AgentSyncInfo syncInfo, ImmutableArray connectionBindings, CancellationToken cancellationToken) + protected override async Task<(DefinitionBase, ImmutableArray, ImmutableArray)> ExecuteAsync(IMcsWorkspace workspace, AuthoringOperationContextBase operationContext, ISyncDataverseClient dataverseClient, AgentSyncInfo syncInfo, CancellationToken cancellationToken) { return (await _synchronizer.PullExistingChangesAsync(workspace.FolderPath, operationContext, workspace.Definition, dataverseClient, syncInfo, cancellationToken).ConfigureAwait(false), ImmutableArray.Empty, ImmutableArray.Empty); } diff --git a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPushHandler.cs b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPushHandler.cs index 5902a20..4dd39a6 100644 --- a/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPushHandler.cs +++ b/src/LanguageServers/PowerPlatformLS/Impl.PullAgent/SyncPushHandler.cs @@ -21,7 +21,7 @@ public SyncPushHandler(CopilotStudio.Sync.IIslandControlPlaneService islandContr { } - protected override async Task<(DefinitionBase, ImmutableArray, ImmutableArray)> ExecuteAsync(IMcsWorkspace workspace, AuthoringOperationContextBase operationContext, ISyncDataverseClient dataverseClient, AgentSyncInfo syncInfo, ImmutableArray connectionBindings, CancellationToken cancellationToken) + protected override async Task<(DefinitionBase, ImmutableArray, ImmutableArray)> ExecuteAsync(IMcsWorkspace workspace, AuthoringOperationContextBase operationContext, ISyncDataverseClient dataverseClient, AgentSyncInfo syncInfo, CancellationToken cancellationToken) { // Fail-closed support gate (TDD D35): push is destructive to the cloud, so it // requires a Supported authoring shape. Classify from the definition AND the @@ -32,10 +32,9 @@ public SyncPushHandler(CopilotStudio.Sync.IIslandControlPlaneService islandContr var classification = AgentClassifier.Classify(workspace.Definition, workspace.FolderPath.ToString()); AuthoringSupportGate.EnsureAllowed(classification, SyncOperation.Push); - await _synchronizer.ProvisionConnectionReferencesAsync(workspace.FolderPath, workspace.Definition, dataverseClient, cancellationToken); - await ConnectionHelper.BindConnectionsAsync(dataverseClient, connectionBindings, _logger, cancellationToken); + await ConnectionHelper.ProvisionConnectionsAsync(_synchronizer, workspace.FolderPath, workspace.Definition, dataverseClient, cancellationToken); - var (workflowResponse, cloudFlowMetadata) = await _synchronizer.UpsertWorkflowForAgentAsync(workspace.FolderPath, dataverseClient, syncInfo.AgentId, cancellationToken); + var (workflowResponse, cloudFlowMetadata) = await _synchronizer.UpsertWorkflowForAgentAsync(workspace.FolderPath, dataverseClient, syncInfo.AgentId, cancellationToken, CopilotStudio.Sync.WorkflowActivationMode.DraftWhenConnectionsUnbound); var (aiPromptResponse, aiPromptMetadata) = await _synchronizer.UpsertAIPromptsForAgentAsync(workspace.FolderPath, dataverseClient, syncInfo.AgentId, cancellationToken); diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/CompletionIntegrationTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/CompletionIntegrationTests.cs index 26e4a02..28b16cf 100644 --- a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/CompletionIntegrationTests.cs +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/CompletionIntegrationTests.cs @@ -1,8 +1,6 @@ namespace Microsoft.PowerPlatformLS.UnitTests.Impl.Language.CopilotStudio { - using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerPlatformLS.Contracts.Internal; - using Microsoft.PowerPlatformLS.Contracts.Internal.Common.DependencyInjection; using Microsoft.PowerPlatformLS.Contracts.Internal.Completion; using Microsoft.PowerPlatformLS.Contracts.Internal.Models; using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; @@ -10,7 +8,6 @@ using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Completion; using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.DependencyInjection; using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Models; - using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -31,7 +28,7 @@ public void Completion_Snapshot(string fileName, string fileNameInTestEnvironmen var doc = world.AddFile(fileNameInTestEnvironment, text); var context = world.GetRequestContext(doc, index); - var rule = world.GetRequiredService>(); + var rule = world.GetRequiredServices>().OfType().Single(); var completions = rule.ComputeCompletion(context, new CompletionContext()); var list = completions.ToList(); @@ -58,7 +55,7 @@ public void TestDI() var doc = world.AddFile("topic2.mcs.yml"); - var rule = world.GetRequiredService>(); + var rule = world.GetRequiredServices>().OfType().Single(); // Verify handlers where registered. world.GetRequiredService(); @@ -117,7 +114,7 @@ public void Test_FxIntellisense() var reqCtx = world.GetRequestContext(doc, search); - var rule = world.GetRequiredService>(); + var rule = world.GetRequiredServices>().OfType().Single(); var results = rule.ComputeCompletion(reqCtx, new CompletionContext()); diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/ConnectionReferenceCompletionRuleTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/ConnectionReferenceCompletionRuleTests.cs new file mode 100644 index 0000000..6f0b404 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/ConnectionReferenceCompletionRuleTests.cs @@ -0,0 +1,132 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +namespace Microsoft.PowerPlatformLS.UnitTests.Impl.Language.CopilotStudio +{ + using Microsoft.CopilotStudio.McsCore; + using Microsoft.PowerPlatformLS.Contracts.Internal; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models.Lsp; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Completion; + using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Models; + using Microsoft.PowerPlatformLS.UnitTests.TestUtilities; + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Text; + using Xunit; + + public class ConnectionReferenceCompletionRuleTests + { + private const string WorkspaceRoot = "c:/agent"; + private const string CacheRelativePath = ".mcs/.connections-cache.json"; + private const string DeclaredReference = "pref_agent.shared_office365.abc123"; + private const string UndeclaredReference = "pref_agent.shared_sharepoint.def456"; + private const string DeclareCommand = "microsoft-copilot-studio.declareConnectionReference"; + + [Fact] + public void ReplacesEntireValue_NoPrefixDoubling() + { + var factory = CreateFactoryWithCache(); + var text = " connectionReference: pref_agent."; + + var item = Complete(factory, text, text.Length).Single(i => i.Label == DeclaredReference); + + Assert.NotNull(item.TextEdit); + Assert.Equal(DeclaredReference, item.TextEdit!.NewText); + + var valueStart = text.IndexOf("pref_agent.", StringComparison.Ordinal); + Assert.Equal(0, item.TextEdit.Range.Start.Line); + Assert.Equal(valueStart, item.TextEdit.Range.Start.Character); + Assert.Equal(text.Length, item.TextEdit.Range.End.Character); + + var applied = text.Substring(0, item.TextEdit.Range.Start.Character) + + item.TextEdit.NewText + + text.Substring(item.TextEdit.Range.End.Character); + Assert.Equal(" connectionReference: pref_agent.shared_office365.abc123", applied); + } + + [Fact] + public void ReplacesEmptyValue_AtInsertionPoint() + { + var factory = CreateFactoryWithCache(); + var text = " connectionReference: "; + + var item = Complete(factory, text, text.Length).Single(i => i.Label == DeclaredReference); + + Assert.NotNull(item.TextEdit); + Assert.Equal(text.Length, item.TextEdit!.Range.Start.Character); + Assert.Equal(text.Length, item.TextEdit.Range.End.Character); + } + + [Fact] + public void DeclaredReference_HasNoDeclareCommandAndSortsFirst() + { + var factory = CreateFactoryWithCache(); + var text = " connectionReference: "; + + var item = Complete(factory, text, text.Length).Single(i => i.Label == DeclaredReference); + + Assert.Null(item.Command); + Assert.Equal("0", item.SortText); + Assert.Contains("bound", item.Detail, StringComparison.Ordinal); + } + + [Fact] + public void UndeclaredReference_EmitsDeclareCommandAndSortsLast() + { + var factory = CreateFactoryWithCache(); + var text = " connectionReference: "; + + var item = Complete(factory, text, text.Length).Single(i => i.Label == UndeclaredReference); + + Assert.NotNull(item.Command); + Assert.Equal(DeclareCommand, item.Command!.Command); + Assert.Equal(UndeclaredReference, Assert.Single(item.Command.Arguments!)); + Assert.Equal("1", item.SortText); + Assert.Contains("not declared", item.Detail, StringComparison.Ordinal); + } + + private static IReadOnlyList Complete(InMemoryFileAccessorFactory factory, string text, int index) + { + var root = new DirectoryPath(WorkspaceRoot); + var document = new McsLspDocument(new FilePath(WorkspaceRoot + "/actions/Foo.mcs.yml"), text, root); + var context = new RequestContext(new FakeLanguage(), new Workspace(root), document, index); + var rule = new ConnectionReferenceCompletionRule(factory); + return rule.ComputeCompletion(context, new CompletionContext()).ToList(); + } + + private static InMemoryFileAccessorFactory CreateFactoryWithCache() + { + var factory = new InMemoryFileAccessorFactory(); + var accessor = factory.Create(new DirectoryPath(WorkspaceRoot)); + var json = "{" + + "\"schemaVersion\":\"2\"," + + "\"connections\":[" + + $"{{\"connectionReferenceLogicalName\":\"{DeclaredReference}\",\"connectorName\":\"shared_office365\",\"boundConnectionExists\":true,\"isDeclared\":true}}," + + $"{{\"connectionReferenceLogicalName\":\"{UndeclaredReference}\",\"connectorName\":\"shared_sharepoint\",\"boundConnectionExists\":false,\"isDeclared\":false}}" + + "]}"; + + using var stream = accessor.OpenWrite(new AgentFilePath(CacheRelativePath)); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(json); + return factory; + } + + private sealed class FakeLanguage : ILanguageAbstraction + { + public LanguageType LanguageType => LanguageType.CopilotStudio; + + public LspDocument CreateDocument(FilePath path, string text, CultureInfo culture, DirectoryPath workspacePath) + => throw new NotImplementedException(); + + public bool IsValidAgentDirectory(DirectoryPath directory, out DirectoryPath validDirectory) + { + validDirectory = directory; + return false; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/ConnectionReferenceValidationRuleTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/ConnectionReferenceValidationRuleTests.cs new file mode 100644 index 0000000..075f23d --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.Language.CopilotStudio/ConnectionReferenceValidationRuleTests.cs @@ -0,0 +1,189 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +namespace Microsoft.PowerPlatformLS.UnitTests.Impl.Language.CopilotStudio +{ + using Microsoft.CopilotStudio.McsCore; + using Microsoft.PowerPlatformLS.Contracts.Internal; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models.Lsp; + using Microsoft.PowerPlatformLS.Contracts.Internal.Validation; + using Microsoft.PowerPlatformLS.Contracts.Lsp.Models; + using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Models; + using Microsoft.PowerPlatformLS.Impl.Language.CopilotStudio.Validation; + using Microsoft.PowerPlatformLS.UnitTests.TestUtilities; + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Text; + using Xunit; + + public class ConnectionReferenceValidationRuleTests + { + private const string WorkspaceRoot = "c:/agent"; + private const string CacheRelativePath = ".mcs/.connections-cache.json"; + + private const string BoundReference = "pref_agent.shared_msnweather.shared-msnweather-bound"; + private const string UnboundReference = "pref_agent.shared_office365.shared-office365-unbound"; + private const string UndeclaredReference = "pref_agent.shared_sharepoint.shared-sharepoint-undeclared"; + private const string UnknownReference = "pref_agent.shared_mystery.shared-mystery-unknown"; + + [Fact] + public void ReportsErrorForUnknownReference() + { + var factory = CreateFactoryWithCache(); + var text = string.Join("\n", new[] + { + "kind: TaskDialog", + $" connectionReference: {UnknownReference}", + }); + + var diagnostics = Validate(factory, text); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("UnknownConnectionReference", diagnostic.Code); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.NotNull(diagnostic.Range); + var range = diagnostic.Range!.Value; + Assert.Equal(1, range.Start.Line); + Assert.Equal(text.Split('\n')[1].IndexOf(UnknownReference, StringComparison.Ordinal), range.Start.Character); + Assert.Equal(range.Start.Character + UnknownReference.Length, range.End.Character); + } + + [Fact] + public void ReportsWarningForUnboundReference() + { + var factory = CreateFactoryWithCache(); + var text = $" connectionReference: {UnboundReference}"; + + var diagnostics = Validate(factory, text); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("UnboundConnectionReference", diagnostic.Code); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + } + + [Fact] + public void ReportsErrorForUndeclaredReference() + { + var factory = CreateFactoryWithCache(); + var text = $" connectionReference: {UndeclaredReference}"; + + var diagnostics = Validate(factory, text); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("UndeclaredConnectionReference", diagnostic.Code); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + } + + [Fact] + public void DoesNotReportForBoundReference() + { + var factory = CreateFactoryWithCache(); + var text = $" connectionReference: {BoundReference}"; + + var diagnostics = Validate(factory, text); + + Assert.Empty(diagnostics); + } + + [Theory] + [InlineData("'")] + [InlineData("\"")] + public void ReportsWarningForQuotedUnboundReference(string quote) + { + var factory = CreateFactoryWithCache(); + var text = $" connectionReference: {quote}{UnboundReference}{quote}"; + + var diagnostics = Validate(factory, text); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal("UnboundConnectionReference", diagnostic.Code); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + var range = diagnostic.Range!.Value; + Assert.Equal(text.IndexOf(UnboundReference, StringComparison.Ordinal), range.Start.Character); + Assert.Equal(range.Start.Character + UnboundReference.Length, range.End.Character); + } + + [Theory] + [InlineData("'")] + [InlineData("\"")] + public void DoesNotReportForQuotedBoundReference(string quote) + { + var factory = CreateFactoryWithCache(); + var text = $" connectionReference: {quote}{BoundReference}{quote}"; + + var diagnostics = Validate(factory, text); + + Assert.Empty(diagnostics); + } + + [Fact] + public void DoesNotReportWhenCacheMissing() + { + var factory = new InMemoryFileAccessorFactory(); + var text = $" connectionReference: {UnknownReference}"; + + var diagnostics = Validate(factory, text); + + Assert.Empty(diagnostics); + } + + [Fact] + public void IgnoresUnrelatedKeys() + { + var factory = CreateFactoryWithCache(); + var text = string.Join("\n", new[] + { + $" connectionReferenceLogicalName: {UnknownReference}", + " connectionReferences:", + }); + + var diagnostics = Validate(factory, text); + + Assert.Empty(diagnostics); + } + + private static IReadOnlyList Validate(InMemoryFileAccessorFactory factory, string text) + { + var root = new DirectoryPath(WorkspaceRoot); + var document = new McsLspDocument(new FilePath(WorkspaceRoot + "/actions/Foo.mcs.yml"), text, root); + var context = new RequestContext(new FakeLanguage(), new Workspace(root), document, 0); + IValidationRule rule = new ConnectionReferenceValidationRule(factory); + return rule.ComputeValidation(context, document).ToList(); + } + + private static InMemoryFileAccessorFactory CreateFactoryWithCache() + { + var factory = new InMemoryFileAccessorFactory(); + var accessor = factory.Create(new DirectoryPath(WorkspaceRoot)); + var json = "{" + + "\"schemaVersion\":\"1\"," + + "\"connections\":[" + + $"{{\"connectionReferenceLogicalName\":\"{BoundReference}\",\"boundConnectionExists\":true}}," + + $"{{\"connectionReferenceLogicalName\":\"{UnboundReference}\",\"boundConnectionExists\":false}}," + + $"{{\"connectionReferenceLogicalName\":\"{UndeclaredReference}\",\"boundConnectionExists\":false,\"isDeclared\":false}}" + + "]}"; + + using var stream = accessor.OpenWrite(new AgentFilePath(CacheRelativePath)); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(json); + return factory; + } + + private sealed class FakeLanguage : ILanguageAbstraction + { + public LanguageType LanguageType => LanguageType.CopilotStudio; + + public LspDocument CreateDocument(FilePath path, string text, CultureInfo culture, DirectoryPath workspacePath) + => throw new NotImplementedException(); + + public bool IsValidAgentDirectory(DirectoryPath directory, out DirectoryPath validDirectory) + { + validDirectory = directory; + return false; + } + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ApplyConnectionBindingsHandlerTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ApplyConnectionBindingsHandlerTests.cs new file mode 100644 index 0000000..61c655b --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ApplyConnectionBindingsHandlerTests.cs @@ -0,0 +1,114 @@ +namespace Microsoft.PowerPlatformLS.UnitTests.Impl.PullAgent.Methods +{ + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.CopilotStudio.Sync; + using Microsoft.CopilotStudio.Sync.Dataverse; + using Microsoft.PowerPlatformLS.Contracts.Internal; + using Microsoft.PowerPlatformLS.Contracts.Internal.Models; + using Microsoft.PowerPlatformLS.Impl.PullAgent; + using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; + using Microsoft.PowerPlatformLS.UnitTests.Impl.Language.CopilotStudio; + using Moq; + using System; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + using Xunit; + + public class ApplyConnectionBindingsHandlerTests + { + private const string TestDataPath = "TestData"; + private const string WorkspacePath = "Workspace/LocalWorkspace"; + private const string TopicsPath = "topics/Goodbye.mcs.yml"; + private const string EnvironmentId = "TestEnvironment"; + private const string AccountId = "testAccount"; + private const string AccountEmail = "testEmail"; + private const string DataverseUrl = "https://test.crm.dynamics.com"; + private const string AgentManagementUrl = "https://test.agentmanagement.com"; + private const string CopilotStudioToken = "CopilotStudioToken"; + private const string DataverseToken = "DataverseToken"; + + [Fact] + public async Task ApplyConnectionBindings_RepublishesDiagnosticsAfterBinding() + { + var workspacePath = Path.GetFullPath(Path.Combine(TestDataPath, WorkspacePath)); + var world = new World(workspacePath); + var doc = world.GetDocument(Path.Combine(workspacePath, TopicsPath)); + Assert.NotNull(doc); + var requestContext = world.GetRequestContext(doc!, 0); + + var diagnosticsPublisher = new Mock(); + var handler = CreateHandler(new TestWorkspaceSynchronizer(), diagnosticsPublisher.Object); + + var response = await handler.HandleRequestAsync(CreateRequest(workspacePath), requestContext, CancellationToken.None); + + Assert.Equal(200, response.Code); + diagnosticsPublisher.Verify( + p => p.PublishAllDiagnosticsAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ApplyConnectionBindings_DiagnosticsRepublishFailure_DoesNotFailResponse() + { + var workspacePath = Path.GetFullPath(Path.Combine(TestDataPath, WorkspacePath)); + var world = new World(workspacePath); + var doc = world.GetDocument(Path.Combine(workspacePath, TopicsPath)); + Assert.NotNull(doc); + var requestContext = world.GetRequestContext(doc!, 0); + + var diagnosticsPublisher = new Mock(); + diagnosticsPublisher + .Setup(p => p.PublishAllDiagnosticsAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("publish failed")); + var handler = CreateHandler(new TestWorkspaceSynchronizer(), diagnosticsPublisher.Object); + + var response = await handler.HandleRequestAsync(CreateRequest(workspacePath), requestContext, CancellationToken.None); + + Assert.Equal(200, response.Code); + } + + private static ApplyConnectionBindingsRequest CreateRequest(string workspacePath) => new() + { + WorkspaceUri = new Uri(workspacePath), + AccountInfo = new AccountInfo + { + AccountId = AccountId, + TenantId = Guid.NewGuid(), + AccountEmail = AccountEmail + }, + EnvironmentInfo = new EnvironmentInfo + { + DataverseUrl = DataverseUrl, + AgentManagementUrl = AgentManagementUrl, + EnvironmentId = EnvironmentId, + DisplayName = "Test Environment" + }, + SolutionVersions = new SolutionInfo + { + CopilotStudioSolutionVersion = new Version(1, 0, 0, 0) + }, + CopilotStudioAccessToken = CopilotStudioToken, + DataverseAccessToken = DataverseToken, + }; + + private static ApplyConnectionBindingsHandler CreateHandler(IConnectionManagementService synchronizer, IDiagnosticsPublisher diagnosticsPublisher) + { + var mockAuthProvider = new Mock(); + mockAuthProvider + .Setup(a => a.AcquireTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("mock-token"); + var accessor = new LspDataverseHttpClientAccessor(mockAuthProvider.Object); + + return new ApplyConnectionBindingsHandler( + new Mock().Object, + synchronizer, + new TestTokenManager(), + new MockDataverseClient(), + new Mock().Object, + accessor, + diagnosticsPublisher, + new Mock().Object); + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ConnectionHelperTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ConnectionHelperTests.cs index bfcd8fc..c1f32cc 100644 --- a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ConnectionHelperTests.cs +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ConnectionHelperTests.cs @@ -1,14 +1,8 @@ namespace Microsoft.PowerPlatformLS.UnitTests.Impl.PullAgent.Methods { - using Microsoft.CommonLanguageServerProtocol.Framework; using Microsoft.CopilotStudio.Sync; - using Microsoft.CopilotStudio.Sync.Dataverse; using Microsoft.PowerPlatformLS.Impl.PullAgent; - using Moq; using System; - using System.Collections.Immutable; - using System.Threading; - using System.Threading.Tasks; using Xunit; public class ConnectionHelperTests @@ -17,113 +11,6 @@ public class ConnectionHelperTests private const string DataverseUrl = "https://test.crm.dynamics.com"; private const string AgentManagementUrl = "https://test.agentmanagement.com"; - [Fact] - public async Task BindConnectionsAsyncBindsAllValidBindingsTest() - { - var dataverseClient = new MockDataverseClient(); - var bindings = ImmutableArray.Create( - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_first", - ConnectionLogicalName = "connection-one", - ConnectionDisplayName = "Connection One" - }, - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_second", - ConnectionLogicalName = "connection-two", - ConnectionDisplayName = "Connection Two" - }); - - await ConnectionHelper.BindConnectionsAsync(dataverseClient, bindings, new Mock().Object, CancellationToken.None); - - Assert.Equal(2, dataverseClient.BindConnectionReferenceCalls.Count); - Assert.Equal("cr_first", dataverseClient.BindConnectionReferenceCalls[0].ConnectionReferenceLogicalName); - Assert.Equal("connection-one", dataverseClient.BindConnectionReferenceCalls[0].ConnectionLogicalName); - Assert.Equal("Connection One", dataverseClient.BindConnectionReferenceCalls[0].ConnectionDisplayName); - Assert.Equal("cr_second", dataverseClient.BindConnectionReferenceCalls[1].ConnectionReferenceLogicalName); - } - - [Fact] - public async Task BindConnectionsAsyncSkipsBindingsWithBlankNamesTest() - { - var dataverseClient = new MockDataverseClient(); - var bindings = ImmutableArray.Create( - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_valid", - ConnectionLogicalName = "connection-valid" - }, - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_missing_connection", - ConnectionLogicalName = " " - }, - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = string.Empty, - ConnectionLogicalName = "connection-missing-ref" - }); - - await ConnectionHelper.BindConnectionsAsync(dataverseClient, bindings, new Mock().Object, CancellationToken.None); - - Assert.Single(dataverseClient.BindConnectionReferenceCalls); - Assert.Equal("cr_valid", dataverseClient.BindConnectionReferenceCalls[0].ConnectionReferenceLogicalName); - } - - [Fact] - public async Task BindConnectionsAsyncWithEmptyArrayIsNoOpTest() - { - var dataverseClient = new MockDataverseClient(); - - await ConnectionHelper.BindConnectionsAsync(dataverseClient, ImmutableArray.Empty, new Mock().Object, CancellationToken.None); - await ConnectionHelper.BindConnectionsAsync(dataverseClient, default, new Mock().Object, CancellationToken.None); - - Assert.Empty(dataverseClient.BindConnectionReferenceCalls); - } - - [Fact] - public async Task BindConnectionsAsyncBindFailureThrowsRedactedExceptionAndLogsSensitiveTest() - { - var logger = new Mock(); - var bindings = ImmutableArray.Create( - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_secretref", - ConnectionLogicalName = "secret-connection" - }); - - var exception = await Assert.ThrowsAsync(() => - ConnectionHelper.BindConnectionsAsync(new MockDataverseClientThrowingBind(), bindings, logger.Object, CancellationToken.None)); - - Assert.Contains("Failed to bind a connection reference", exception.Message); - Assert.DoesNotContain("cr_secretref", exception.Message); - Assert.NotNull(exception.InnerException); - logger.Verify( - l => l.LogSensitiveInformation( - It.Is(s => s.Contains("cr_secretref")), - It.Is(s => !s.Contains("cr_secretref"))), - Times.Once); - } - - [Fact] - public async Task BindConnectionsAsyncCancellationIsNotWrappedTest() - { - var dataverseClient = new Mock(); - dataverseClient - .Setup(c => c.BindConnectionReferenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new OperationCanceledException()); - var bindings = ImmutableArray.Create( - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_ref", - ConnectionLogicalName = "connection" - }); - - await Assert.ThrowsAsync(() => - ConnectionHelper.BindConnectionsAsync(dataverseClient.Object, bindings, new Mock().Object, CancellationToken.None)); - } - [Fact] public void BuildDefaultSyncInfoMapsRequestFieldsTest() { @@ -137,7 +24,7 @@ public void BuildDefaultSyncInfoMapsRequestFieldsTest() { CopilotStudioSolutionVersion = new Version(1, 0, 0, 0) }; - var request = new PreparePushRequest + var request = new SyncAgentRequest { WorkspaceUri = new Uri("file:///c:/agent"), AccountInfo = accountInfo, diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/LspExceptionHandlerTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/LspExceptionHandlerTests.cs new file mode 100644 index 0000000..fcb07a6 --- /dev/null +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/LspExceptionHandlerTests.cs @@ -0,0 +1,68 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +namespace Microsoft.PowerPlatformLS.UnitTests.Impl.PullAgent.Methods +{ + using Microsoft.CommonLanguageServerProtocol.Framework; + using Microsoft.PowerPlatformLS.Impl.PullAgent; + using Moq; + using System; + using System.IO; + using System.Net.Http; + using System.Threading; + using Xunit; + + public class LspExceptionHandlerTests + { + [Fact] + public void Handle_OperationCanceled_WhenTokenCancelled_Returns499() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var (code, message) = LspExceptionHandler.Handle(new OperationCanceledException(), new Mock().Object, cts.Token); + + Assert.Equal(499, code); + Assert.Contains("cancelled", message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Handle_OperationCanceled_WhenTokenNotCancelled_Returns504() + { + var (code, _) = LspExceptionHandler.Handle(new OperationCanceledException("timed out"), new Mock().Object, CancellationToken.None); + + Assert.Equal(504, code); + } + + [Fact] + public void Handle_HttpRequestException_Returns502() + { + var (code, _) = LspExceptionHandler.Handle(new HttpRequestException("network down"), new Mock().Object, CancellationToken.None); + + Assert.Equal(502, code); + } + + [Fact] + public void Handle_FileNotFound_Returns400() + { + var (code, _) = LspExceptionHandler.Handle(new FileNotFoundException("missing cache"), new Mock().Object, CancellationToken.None); + + Assert.Equal(400, code); + } + + [Fact] + public void Handle_InvalidOperation_Returns400() + { + var (code, _) = LspExceptionHandler.Handle(new InvalidOperationException("bad input"), new Mock().Object, CancellationToken.None); + + Assert.Equal(400, code); + } + + [Fact] + public void Handle_UnexpectedException_Returns500() + { + var (code, _) = LspExceptionHandler.Handle(new Exception("boom"), new Mock().Object, CancellationToken.None); + + Assert.Equal(500, code); + } + } +} diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/PreparePushHandlerTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/PreparePushHandlerTests.cs deleted file mode 100644 index b06e2e2..0000000 --- a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/PreparePushHandlerTests.cs +++ /dev/null @@ -1,202 +0,0 @@ -namespace Microsoft.PowerPlatformLS.UnitTests.Impl.PullAgent.Methods -{ - using Microsoft.Agents.ObjectModel; - using Microsoft.Agents.Platform.Content.Exceptions; - using Microsoft.CommonLanguageServerProtocol.Framework; - using Microsoft.CopilotStudio.McsCore; - using Microsoft.CopilotStudio.Sync; - using Microsoft.CopilotStudio.Sync.Dataverse; - using Microsoft.PowerPlatformLS.Contracts.Internal.Models; - using Microsoft.PowerPlatformLS.Impl.PullAgent; - using Microsoft.PowerPlatformLS.Impl.PullAgent.Auth; - using Microsoft.PowerPlatformLS.UnitTests.Impl.Language.CopilotStudio; - using Moq; - using System; - using System.Collections.Generic; - using System.IO; - using System.Threading; - using System.Threading.Tasks; - using Xunit; - using DirectoryPath = Microsoft.CopilotStudio.McsCore.DirectoryPath; - - public class PreparePushHandlerTests - { - private const string TestDataPath = "TestData"; - private const string WorkspacePath = "Workspace/LocalWorkspace"; - private const string TopicsPath = "topics/Goodbye.mcs.yml"; - private const string EnvironmentId = "TestEnvironment"; - private const string AccountId = "testAccount"; - private const string AccountEmail = "testEmail"; - private const string DataverseUrl = "https://test.crm.dynamics.com"; - private const string AgentManagementUrl = "https://test.agentmanagement.com"; - private const string CopilotStudioToken = "CopilotStudioToken"; - private const string DataverseToken = "DataverseToken"; - - [Fact] - public async Task PreparePushValidWorkspaceReturns200Test() - { - var (requestContext, request) = CreateSetup(); - var handler = CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(request, requestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.True(response.AgentConnections.IsDefaultOrEmpty); - } - - [Fact] - public async Task PreparePushReturnsProvisionedConnectionsTest() - { - var (requestContext, request) = CreateSetup(); - var connection = new ConnectionNeeded - { - ConnectionReferenceLogicalName = "cr_needed", - ConnectorId = "connector-id", - ConnectorName = "My Connector" - }; - var handler = CreateHandler(new MockDataverseClient(), new PreparePushConnectionReturningSynchronizer(connection)); - - var response = await handler.HandleRequestAsync(request, requestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - var returned = Assert.Single(response.AgentConnections); - Assert.Equal("cr_needed", returned.ConnectionReferenceLogicalName); - } - - [Fact] - public async Task PreparePush_NonCliTemplate_ProceedsAsClassic() - { - // Issue #292: a classic agent created from a non-default gallery template (the - // fixture's template: sdkagent-1.0.0) has no native CLI evidence, so it is - // Classic/Supported. The push gate allows it and prepare proceeds to provisioning - - // the template is a template, not an authoring shape, and must not fail closed. - var (requestContext, request) = CreateSetup("Workspace/UnrecognizedTemplateWorkspace"); - var synchronizer = new PreparePushProvisionTrackingSynchronizer(); - var handler = CreateHandler(new MockDataverseClient(), synchronizer); - - var response = await handler.HandleRequestAsync(request, requestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.True(synchronizer.ProvisionAttempted); - } - - [Fact] - public async Task PreparePushBadRequestReturnsStatusCodeTest() - { - var (requestContext, request) = CreateSetup(); - var logger = new Mock(); - var synchronizer = new PreparePushThrowingSynchronizer(new DataverseBadRequestException( - errorCodeName: "BadRequest", - errorCodeValue: "400", - serviceRequestId: Guid.NewGuid().ToString(), - message: "Invalid connector", - innerException: null)); - var handler = CreateHandler(new MockDataverseClient(), synchronizer, logger.Object); - - var response = await handler.HandleRequestAsync(request, requestContext, CancellationToken.None); - - Assert.Equal(400, response.Code); - Assert.Contains("BadRequest", response.Message); - logger.Verify(l => l.LogException(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); - } - - [Fact] - public async Task PreparePushGenericExceptionReturns500Test() - { - var (requestContext, request) = CreateSetup(); - var synchronizer = new PreparePushThrowingSynchronizer(new InvalidOperationException("provision failed")); - var handler = CreateHandler(new MockDataverseClient(), synchronizer); - - var response = await handler.HandleRequestAsync(request, requestContext, CancellationToken.None); - - Assert.Equal(500, response.Code); - Assert.Contains("provision failed", response.Message); - } - - private static (RequestContext, PreparePushRequest) CreateSetup(string? customWorkspace = null) - { - var workspacePath = Path.GetFullPath(Path.Combine(TestDataPath, customWorkspace ?? WorkspacePath)); - var world = new World(workspacePath); - var doc = world.GetDocument(Path.Combine(workspacePath, TopicsPath)); - var requestContext = world.GetRequestContext(doc, 0); - - var request = new PreparePushRequest - { - WorkspaceUri = new Uri(workspacePath), - AccountInfo = new AccountInfo - { - AccountId = AccountId, - TenantId = Guid.NewGuid(), - AccountEmail = AccountEmail - }, - EnvironmentInfo = new EnvironmentInfo - { - DataverseUrl = DataverseUrl, - AgentManagementUrl = AgentManagementUrl, - EnvironmentId = EnvironmentId, - DisplayName = "Test Environment" - }, - SolutionVersions = new SolutionInfo - { - CopilotStudioSolutionVersion = new Version(1, 0, 0, 0) - }, - CopilotStudioAccessToken = CopilotStudioToken, - DataverseAccessToken = DataverseToken - }; - - return (requestContext, request); - } - - private static PreparePushHandler CreateHandler(ISyncDataverseClient dataverseClient, IWorkspaceSynchronizer synchronizer, ILspLogger? logger = null) - { - var mockAuthProvider = new Mock(); - mockAuthProvider.Setup(a => a.AcquireTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("mock-token"); - var accessor = new LspDataverseHttpClientAccessor(mockAuthProvider.Object); - - return new PreparePushHandler( - new Mock().Object, - synchronizer, - new TestTokenManager(), - dataverseClient, - accessor, - logger ?? new Mock().Object); - } - } - - internal sealed class PreparePushConnectionReturningSynchronizer : TestWorkspaceSynchronizer - { - private readonly ConnectionNeeded _connection; - - public PreparePushConnectionReturningSynchronizer(ConnectionNeeded connection) - { - _connection = connection; - } - - public override Task> GetNewAgentConnectionReferencesAsync(DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) - => Task.FromResult>(new[] { _connection }); - } - - internal sealed class PreparePushProvisionTrackingSynchronizer : TestWorkspaceSynchronizer - { - public bool ProvisionAttempted { get; private set; } - - public override Task PushCustomConnectorsAsync(DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) - { - ProvisionAttempted = true; - return base.PushCustomConnectorsAsync(workspaceFolder, dataverseClient, cancellationToken); - } - } - - internal sealed class PreparePushThrowingSynchronizer : TestWorkspaceSynchronizer - { - private readonly Exception _exception; - - public PreparePushThrowingSynchronizer(Exception exception) - { - _exception = exception; - } - - public override Task PushCustomConnectorsAsync(DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) - => throw _exception; - } -} diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/PrepareReattachHandlerTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/PrepareReattachHandlerTests.cs deleted file mode 100644 index 69ca50f..0000000 --- a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/PrepareReattachHandlerTests.cs +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. - -namespace Microsoft.PowerPlatformLS.UnitTests.Impl.PullAgent.Methods -{ - using Microsoft.Agents.Platform.Content.Exceptions; - using Microsoft.CommonLanguageServerProtocol.Framework; - using Microsoft.CopilotStudio.McsCore; - using Microsoft.CopilotStudio.Sync; - using Microsoft.CopilotStudio.Sync.Dataverse; - using Microsoft.PowerPlatformLS.Contracts.Internal.Models; - using Microsoft.PowerPlatformLS.Impl.PullAgent; - using Microsoft.PowerPlatformLS.UnitTests.Impl.Language.CopilotStudio; - using Moq; - using System; - using System.IO; - using System.Threading; - using System.Threading.Tasks; - using Xunit; - - public class PrepareReattachHandlerTests - { - private const string TestDataPath = "TestData"; - private const string WorkspacePath = "Workspace/LocalWorkspace"; - private const string TopicsPath = "topics/Goodbye.mcs.yml"; - private const string EnvironmentId = "TestEnvironment"; - private const string AccountId = "testAccount"; - private const string AccountEmail = "testEmail"; - private const string DataverseUrl = "https://test.crm.dynamics.com"; - private const string AgentManagementUrl = "https://test.agentmanagement.com"; - private const string CopilotStudioToken = "CopilotStudioToken"; - private const string DataverseToken = "DataverseToken"; - - [Fact] - public async Task PrepareReattachValidDirectoryTest() - { - var context = CreatePrepareTestSetup(); - var synchronizer = new TestWorkspaceSynchronizer(); - var handler = TestHandlerFactory.CreatePrepareHandler(new MockDataverseClient(), synchronizer); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.NotNull(response.AgentSyncInfo); - Assert.Equal(EnvironmentId, response.AgentSyncInfo!.EnvironmentId); - Assert.Equal(AccountId, response.AgentSyncInfo?.AccountInfo?.AccountId); - Assert.True(response.IsNewAgent); - Assert.Equal(0, synchronizer.SavedSyncInfoCount); - } - - [Fact] - public async Task PrepareReattachCreateAgentFailureTest() - { - var context = CreatePrepareTestSetup(); - var failingClient = new Mock(); - failingClient.Setup(c => c.GetAgentIdBySchemaNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Guid.Empty); - failingClient.Setup(c => c.CreateNewAgentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Dataverse failure!")); - var handler = TestHandlerFactory.CreatePrepareHandler(failingClient.Object, new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.NotEqual(200, response.Code); - Assert.Equal(Guid.Empty, response.AgentSyncInfo?.AgentId); - Assert.NotNull(response.Message); - } - - [Fact] - public async Task PrepareReattachInvalidDirectoryTest() - { - var context = CreatePrepareTestSetup("Workspace/InvalidWorkspace"); - var handler = TestHandlerFactory.CreatePrepareHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.NotEqual(200, response.Code); - Assert.Equal(Guid.Empty, response.AgentSyncInfo?.AgentId); - Assert.NotNull(response.Message); - } - - [Fact] - public async Task PrepareReattachAlreadyConnectedTest() - { - var context = CreatePrepareTestSetup(); - var handler = TestHandlerFactory.CreatePrepareHandler(new MockDataverseClient(), new TestWorkspaceSynchronizerSyncInfoExists()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(400, response.Code); - Assert.Equal(Guid.Empty, response.AgentSyncInfo?.AgentId); - Assert.NotNull(response.Message); - Assert.False(response.IsNewAgent); - } - - [Fact] - public async Task PrepareReattachAlreadyConnectedMessageDoesNotLeakUrlTest() - { - var context = CreatePrepareTestSetup(); - var handler = TestHandlerFactory.CreatePrepareHandler(new MockDataverseClient(), new TestWorkspaceSynchronizerSyncInfoExists()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(400, response.Code); - Assert.DoesNotContain(DataverseUrl, response.Message); - Assert.DoesNotContain(AgentManagementUrl, response.Message); - } - - [Fact] - public async Task PrepareReattachRemoteAgentExistsTest() - { - var context = CreatePrepareTestSetup(); - var mockClient = new Mock(); - mockClient.Setup(c => c.GetAgentIdBySchemaNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Guid.NewGuid()); - var handler = TestHandlerFactory.CreatePrepareHandler(mockClient.Object, new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.False(response.IsNewAgent); - } - - [Fact] - public async Task PrepareReattachReturnsAgentConnectionsTest() - { - var context = CreatePrepareTestSetup(); - var handler = TestHandlerFactory.CreatePrepareHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.True(response.AgentConnections.IsDefaultOrEmpty); - } - - [Fact] - public async Task PrepareReattachDataverseBadRequestTest() - { - var context = CreatePrepareTestSetup(); - var mockLogger = new Mock(); - var badRequestClient = new Mock(); - badRequestClient.Setup(c => c.GetAgentIdBySchemaNameAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new DataverseBadRequestException( - errorCodeName: "BadRequest", - errorCodeValue: "400", - serviceRequestId: Guid.NewGuid().ToString(), - message: "Invalid schema name", - innerException: null)); - var handler = TestHandlerFactory.CreatePrepareHandler(badRequestClient.Object, new TestWorkspaceSynchronizer(), mockLogger.Object); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(400, response.Code); - Assert.Contains("BadRequest", response.Message); - mockLogger.Verify(l => l.LogException(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); - } - - [Fact] - public async Task PrepareReattachWithExceptionTest() - { - var context = CreatePrepareTestSetup(); - var throwingClient = new Mock(); - throwingClient.Setup(c => c.GetAgentIdBySchemaNameAsync(It.IsAny(), It.IsAny())).Throws(new InvalidOperationException("invalid operation exception")); - var handler = TestHandlerFactory.CreatePrepareHandler(throwingClient.Object, new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(500, response.Code); - Assert.Contains("exception", response.Message); - } - - [Fact] - public async Task PrepareReattachWithConnectionTrackingDoesNotFailTest() - { - var context = CreatePrepareTestSetup(); - var trackingClient = new MockDataverseClientWithConnectionTracking(); - var handler = TestHandlerFactory.CreatePrepareHandler(trackingClient, new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.Empty(trackingClient.ProvisionedConnections); - } - - [Fact] - public async Task PrepareReattach_NonCliTemplate_ProceedsAsClassic() - { - // Issue #292: a classic agent created from a non-default gallery template (the - // fixture's template: sdkagent-1.0.0) has no native CLI evidence, so it is - // Classic/Supported. Prepare-reattach proceeds and creates the new agent instead of - // failing closed - the template is a template, not an authoring shape. - var context = CreatePrepareTestSetup("Workspace/UnrecognizedTemplateWorkspace"); - var handler = TestHandlerFactory.CreatePrepareHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.True(response.IsNewAgent); - } - - [Fact] - public async Task PrepareReattach_ComponentCollectionRoot_NotBlockedByGateTest() - { - var dir = Path.GetFullPath(Path.Combine(TestDataPath, "WorkspaceWithCC")); - var world = new World(dir); - var ccDir = Path.Combine(dir, "MyCC333"); - var workspace = world.GetWorkspace(ccDir); - var doc = workspace.GetDocumentOrThrow(new AgentFilePath("collection.mcs.yml")); - var requestContext = world.GetRequestContext(doc, 0); - - var request = new PrepareReattachRequest - { - WorkspaceUri = new Uri(ccDir), - AccountInfo = new AccountInfo - { - AccountId = AccountId, - TenantId = Guid.NewGuid(), - AccountEmail = AccountEmail - }, - EnvironmentInfo = new EnvironmentInfo - { - DataverseUrl = DataverseUrl, - AgentManagementUrl = AgentManagementUrl, - EnvironmentId = EnvironmentId, - DisplayName = "Test Environment" - }, - SolutionVersions = new SolutionInfo - { - CopilotStudioSolutionVersion = new Version(1, 0, 0, 0) - }, - CopilotStudioAccessToken = CopilotStudioToken, - DataverseAccessToken = DataverseToken - }; - - var handler = TestHandlerFactory.CreatePrepareHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer()); - - var response = await handler.HandleRequestAsync(request, requestContext, CancellationToken.None); - - Assert.Equal(200, response.Code); - Assert.True(response.IsNewAgent); - } - - private PrepareReattachTestContext CreatePrepareTestSetup(string? customWorkspace = null) - { - var workspacePath = Path.GetFullPath(Path.Combine(TestDataPath, customWorkspace ?? WorkspacePath)); - World world; - RequestContext requestContext; - - if (Directory.Exists(workspacePath) && File.Exists(Path.Combine(workspacePath, TopicsPath))) - { - world = new World(workspacePath); - var path = Path.Combine(workspacePath, TopicsPath); - var doc = world.GetDocument(path); - requestContext = world.GetRequestContext(doc, 0); - } - else - { - world = new World(); - var doc = world.AddFile("topic2.mcs.yml"); - requestContext = world.GetRequestContext(doc, 0); - } - - var request = new PrepareReattachRequest - { - WorkspaceUri = new Uri(workspacePath), - AccountInfo = new AccountInfo - { - AccountId = AccountId, - TenantId = Guid.NewGuid(), - AccountEmail = AccountEmail - }, - EnvironmentInfo = new EnvironmentInfo - { - DataverseUrl = DataverseUrl, - AgentManagementUrl = AgentManagementUrl, - EnvironmentId = EnvironmentId, - DisplayName = "Test Environment" - }, - SolutionVersions = new SolutionInfo - { - CopilotStudioSolutionVersion = new Version(1, 0, 0, 0) - }, - CopilotStudioAccessToken = CopilotStudioToken, - DataverseAccessToken = DataverseToken - }; - - return new PrepareReattachTestContext - { - World = world, - RequestContext = requestContext, - Request = request - }; - } - } - - internal class PrepareReattachTestContext - { - public required World World { get; init; } - - public required RequestContext RequestContext { get; init; } - - public required PrepareReattachRequest Request { get; init; } - } -} diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ReattachAgentHandlerTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ReattachAgentHandlerTests.cs index 10dcc6d..2cb3898 100644 --- a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ReattachAgentHandlerTests.cs +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/ReattachAgentHandlerTests.cs @@ -37,23 +37,42 @@ public class ReattachAgentHandlerTests private const string DataverseToken = "DataverseToken"; [Fact] - public async Task ReattachAgentFinalizesWhenPreparedTest() + public async Task ReattachAgentValidWorkspaceCreatesNewAgentTest() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); - var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer(), CreateOperationProvider()); + var synchronizer = new TestWorkspaceSynchronizer(); + var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), synchronizer, CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); Assert.Equal(200, response.Code); Assert.NotNull(response.AgentSyncInfo); + Assert.Equal(EnvironmentId, response.AgentSyncInfo!.EnvironmentId); + Assert.True(response.IsNewAgent); + Assert.True(synchronizer.ReattachCalled); + Assert.Equal(1, synchronizer.SavedSyncInfoCount); + } + + [Fact] + public async Task ReattachAgentRemoteAgentExistsDoesNotCreateTest() + { + var context = CreateTestSetup(); + var existingAgentId = Guid.NewGuid(); + var dataverseClient = new MockDataverseClientWithExistingAgent(existingAgentId); + var handler = TestHandlerFactory.CreateHandler(dataverseClient, new TestWorkspaceSynchronizer(), CreateOperationProvider()); + + var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); + + Assert.Equal(200, response.Code); + Assert.False(response.IsNewAgent); + Assert.Equal(existingAgentId, response.AgentSyncInfo!.AgentId); + Assert.False(dataverseClient.CreateNewAgentCalled); } [Fact] public async Task ReattachAgentAlreadyConnectedReturns400Test() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizerSyncInfoExists(), CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); @@ -61,132 +80,109 @@ public async Task ReattachAgentAlreadyConnectedReturns400Test() Assert.Equal(400, response.Code); Assert.Contains("already connected", response.Message); Assert.Equal(Guid.Empty, response.AgentSyncInfo?.AgentId); + Assert.False(response.IsNewAgent); } [Fact] - public async Task ReattachAgentFinalizesWithPreparedSyncInfoFromRequestTest() + public async Task ReattachAgentAlreadyConnectedMessageDoesNotLeakUrlTest() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); - var synchronizer = new TestWorkspaceSynchronizer(); - var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), synchronizer, CreateOperationProvider()); + var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizerSyncInfoExists(), CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - Assert.Equal(200, response.Code); - Assert.NotNull(response.AgentSyncInfo); - Assert.Equal(context.Request.AgentSyncInfo.AgentId, response.AgentSyncInfo.AgentId); - Assert.Equal(1, synchronizer.SavedSyncInfoCount); + Assert.Equal(400, response.Code); + Assert.DoesNotContain(DataverseUrl, response.Message); + Assert.DoesNotContain(AgentManagementUrl, response.Message); } [Fact] - public async Task ReattachAgentNotPreparedReturns400Test() + public async Task ReattachAgentInvalidDirectoryReturns400Test() { - var context = CreateTestSetup(); + var context = CreateTestSetup("Workspace/InvalidWorkspace"); var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer(), CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - Assert.Equal(400, response.Code); + Assert.NotEqual(200, response.Code); Assert.Equal(Guid.Empty, response.AgentSyncInfo?.AgentId); Assert.NotNull(response.Message); } [Fact] - public async Task ReattachAgentPropagatesIsNewAgentFromRequestTest() + public async Task ReattachAgentCreateAgentFailureTest() { var context = CreateTestSetup(); - context.Request.IsNewAgent = true; - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); - var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer(), CreateOperationProvider()); + var failingClient = new Mock(); + failingClient.Setup(c => c.GetAgentIdBySchemaNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Guid.Empty); + failingClient.Setup(c => c.CreateNewAgentAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Dataverse failure!")); + var synchronizer = new TestWorkspaceSynchronizer(); + var handler = TestHandlerFactory.CreateHandler(failingClient.Object, synchronizer, CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - Assert.Equal(200, response.Code); - Assert.True(response.IsNewAgent); + Assert.NotEqual(200, response.Code); + Assert.Equal(Guid.Empty, response.AgentSyncInfo?.AgentId); + Assert.Equal(0, synchronizer.SavedSyncInfoCount); } [Fact] - public async Task ReattachAgentBindsConnectionBindingsTest() + public async Task ReattachAgentDoesNotBindConnectionsTest() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); - context.Request.ConnectionBindings = ImmutableArray.Create( - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_testref", - ConnectionLogicalName = "shared-test-connection", - ConnectionDisplayName = "My Test Connection" - }, - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_blank", - ConnectionLogicalName = string.Empty - }); - var dataverseClient = new MockDataverseClient(); var handler = TestHandlerFactory.CreateHandler(dataverseClient, new TestWorkspaceSynchronizer(), CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); Assert.Equal(200, response.Code); - Assert.Single(dataverseClient.BindConnectionReferenceCalls); - Assert.Equal("cr_testref", dataverseClient.BindConnectionReferenceCalls[0].ConnectionReferenceLogicalName); - Assert.Equal("shared-test-connection", dataverseClient.BindConnectionReferenceCalls[0].ConnectionLogicalName); - Assert.Equal("My Test Connection", dataverseClient.BindConnectionReferenceCalls[0].ConnectionDisplayName); + Assert.Empty(dataverseClient.BindConnectionReferenceCalls); } [Fact] - public async Task ReattachAgentBindsBeforeUpsertingWorkflowsTest() + public async Task ReattachAgentClearsComponentSyncBaselinesTest() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); - context.Request.ConnectionBindings = ImmutableArray.Create( - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_testref", - ConnectionLogicalName = "shared-test-connection" - }); - - var dataverseClient = new MockDataverseClient(); - var synchronizer = new TestWorkspaceSynchronizerRecordingOrder(dataverseClient); - var handler = TestHandlerFactory.CreateHandler(dataverseClient, synchronizer, CreateOperationProvider()); + var synchronizer = new TestWorkspaceSynchronizer(); + var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), synchronizer, CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); Assert.Equal(200, response.Code); - Assert.True(dataverseClient.BindConnectionReferenceCalls.Count > 0); - Assert.True(synchronizer.UpsertWorkflowInvokedAfterBind); + Assert.Equal(1, synchronizer.ClearComponentSyncBaselinesCount); } [Fact] - public async Task ReattachAgentBindFailureFailsAndDoesNotSaveSyncInfoTest() + public async Task ReattachAgentUploadsWorkflowsWithActivateWhenConnectionsBoundModeTest() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); - context.Request.ConnectionBindings = ImmutableArray.Create( - new ConnectionBindingInput - { - ConnectionReferenceLogicalName = "cr_testref", - ConnectionLogicalName = "shared-test-connection" - }); + var synchronizer = new TestWorkspaceSynchronizerRecordingActivation(); + var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), synchronizer, CreateOperationProvider()); - var synchronizer = new TestWorkspaceSynchronizer(); - var handler = TestHandlerFactory.CreateHandler(new MockDataverseClientThrowingBind(), synchronizer, CreateOperationProvider()); + var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); + + Assert.Equal(200, response.Code); + Assert.Equal(WorkflowActivationMode.ActivateWhenConnectionsBound, synchronizer.CapturedActivationMode); + } + + [Fact] + public async Task ReattachAgentProvisionsConnectionReferencesTest() + { + var context = CreateTestSetup(); + var synchronizer = new TestWorkspaceSynchronizerProvisionTracking(); + var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), synchronizer, CreateOperationProvider()); var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); - Assert.Equal(400, response.Code); - Assert.Contains("Failed to bind a connection reference", response.Message); - Assert.DoesNotContain("cr_testref", response.Message); - Assert.Equal(0, synchronizer.SavedSyncInfoCount); + Assert.Equal(200, response.Code); + Assert.True(synchronizer.PushCustomConnectorsCalled); + Assert.True(synchronizer.ProvisionConnectionReferencesCalled); } [Fact] public async Task ReattachAgentDataverseBadRequestTest() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); var mockLogger = new Mock(); var synchronizer = new TestWorkspaceSynchronizerThrowingWorkflow( new DataverseBadRequestException("BadRequest", "400", Guid.NewGuid().ToString(), "Invalid workflow", null)); @@ -203,7 +199,6 @@ public async Task ReattachAgentDataverseBadRequestTest() public async Task ReattachAgentWithExceptionTest() { var context = CreateTestSetup(); - context.Request.AgentSyncInfo = CreatePreparedSyncInfo(); var synchronizer = new TestWorkspaceSynchronizerThrowingWorkflow(new InvalidOperationException("invalid operation exception")); var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), synchronizer, CreateOperationProvider()); @@ -213,6 +208,63 @@ public async Task ReattachAgentWithExceptionTest() Assert.Contains("exception", response.Message); } + [Fact] + public async Task ReattachAgent_NonCliTemplate_ProceedsAsClassicTest() + { + // Issue #292: a classic agent created from a non-default gallery template has no native + // CLI evidence, so it is Classic/Supported. Reattach proceeds and creates the new agent + // instead of failing closed - the template is a template, not an authoring shape. + var context = CreateTestSetup("Workspace/UnrecognizedTemplateWorkspace"); + var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer(), CreateOperationProvider()); + + var response = await handler.HandleRequestAsync(context.Request, context.RequestContext, CancellationToken.None); + + Assert.Equal(200, response.Code); + Assert.True(response.IsNewAgent); + } + + [Fact] + public async Task ReattachAgent_ComponentCollectionRoot_NotBlockedByGateTest() + { + var dir = Path.GetFullPath(Path.Combine(TestDataPath, "WorkspaceWithCC")); + var world = new World(dir); + var ccDir = Path.Combine(dir, "MyCC333"); + var workspace = world.GetWorkspace(ccDir); + var doc = workspace.GetDocumentOrThrow(new AgentFilePath("collection.mcs.yml")); + var requestContext = world.GetRequestContext(doc, 0); + + var request = new ReattachAgentRequest + { + WorkspaceUri = new Uri(ccDir), + AccountInfo = new AccountInfo + { + AccountId = AccountId, + TenantId = Guid.NewGuid(), + AccountEmail = AccountEmail + }, + EnvironmentInfo = new EnvironmentInfo + { + DataverseUrl = DataverseUrl, + AgentManagementUrl = AgentManagementUrl, + EnvironmentId = EnvironmentId, + DisplayName = "Test Environment" + }, + SolutionVersions = new SolutionInfo + { + CopilotStudioSolutionVersion = new Version(1, 0, 0, 0) + }, + CopilotStudioAccessToken = CopilotStudioToken, + DataverseAccessToken = DataverseToken + }; + + var handler = TestHandlerFactory.CreateHandler(new MockDataverseClient(), new TestWorkspaceSynchronizer(), CreateOperationProvider()); + + var response = await handler.HandleRequestAsync(request, requestContext, CancellationToken.None); + + Assert.Equal(200, response.Code); + Assert.True(response.IsNewAgent); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -333,24 +385,6 @@ private static IOperationContextProvider CreateOperationProvider() ); } - private static AgentSyncInfo CreatePreparedSyncInfo() => new() - { - AgentId = Guid.NewGuid(), - EnvironmentId = EnvironmentId, - DataverseEndpoint = new Uri(DataverseUrl), - AgentManagementEndpoint = new Uri(AgentManagementUrl), - AccountInfo = new AccountInfo - { - AccountId = AccountId, - TenantId = Guid.NewGuid(), - AccountEmail = AccountEmail - }, - SolutionVersions = new SolutionInfo - { - CopilotStudioSolutionVersion = new Version(1, 0, 0, 0) - }, - }; - } @@ -374,8 +408,11 @@ public void SetWorkflowsForAgent(WorkflowMetadata[] workflows) _workflowsForAgent = workflows; } + public bool CreateNewAgentCalled { get; private set; } + public virtual Task CreateNewAgentAsync(string newAgentName, string schemaName, AuthoringShape authoringShape, CancellationToken cancellationToken) { + CreateNewAgentCalled = true; var fakeAgent = new AgentInfo { AgentId = Guid.NewGuid(), @@ -436,6 +473,14 @@ public virtual Task BindConnectionReferenceAsync(string connectionReferenceLogic return Task.CompletedTask; } + public List<(Guid WorkflowId, bool Activate)> SetWorkflowStateCalls { get; } = new(); + + public virtual Task SetWorkflowStateAsync(Guid workflowId, bool activate, CancellationToken cancellationToken) + { + SetWorkflowStateCalls.Add((workflowId, activate)); + return Task.CompletedTask; + } + public void SetConnectionReferences(ConnectionReferenceInfo[] connectionReferences) { _connectionReferencesByLogicalName = connectionReferences.ToDictionary(x => x.ConnectionReferenceLogicalName, StringComparer.OrdinalIgnoreCase); @@ -503,20 +548,6 @@ public static ReattachAgentHandler CreateHandler(ISyncDataverseClient dataverseC opProvider, logger ?? new Mock().Object); } - - public static PrepareReattachHandler CreatePrepareHandler(ISyncDataverseClient dataverseClient, IWorkspaceSynchronizer workspace, ILspLogger? logger = null) - { - var mockAuthProvider = new Mock(); - mockAuthProvider.Setup(a => a.AcquireTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("mock-token"); - var accessor = new LspDataverseHttpClientAccessor(mockAuthProvider.Object); - return new PrepareReattachHandler( - new Mock().Object, - workspace, - new TestTokenManager(), - dataverseClient, - accessor, - logger ?? new Mock().Object); - } } internal class TestTokenManager : ITokenManager @@ -526,7 +557,7 @@ public void SetTokens(string dataverseToken, string copilotStudioToken) } } - internal class TestWorkspaceSynchronizer : IWorkspaceSynchronizer + internal class TestWorkspaceSynchronizer : IWorkspaceSynchronizer, IConnectionManagementService, IWorkflowActivationService { public bool ReattachCalled { get; private set; } = false; @@ -561,6 +592,13 @@ public virtual Task SaveSyncInfoAsync(Microsoft.CopilotStudio.McsCore.DirectoryP return Task.CompletedTask; } + public int ClearComponentSyncBaselinesCount { get; private set; } + + public virtual void ClearComponentSyncBaselines(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder) + { + ClearComponentSyncBaselinesCount++; + } + public Task<(PvaComponentChangeSet, ImmutableArray)> GetLocalChangesAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase workspaceDefinition, ISyncDataverseClient dataverseClient, AgentSyncInfo syncInfo, CancellationToken cancellationToken) { return Task.FromResult((new PvaComponentChangeSet(Enumerable.Empty(), null, "token"), ImmutableArray.Empty)); @@ -605,7 +643,7 @@ public Task SyncWorkspaceAsync(Microsoft.CopilotStudio.McsCor }); } - public virtual Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken) + public virtual Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken, WorkflowActivationMode activationMode = WorkflowActivationMode.PreserveSavedState) { var emptyMetadata = new CloudFlowMetadata { @@ -630,7 +668,7 @@ public Task GetWorkflowsAsync(Microsoft.CopilotStudio.McsCore public Task ProvisionConnectionReferencesAsync(DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken, IReadOnlyDictionary? pushedConnectorIds = null) => Task.CompletedTask; - public Task ProvisionConnectionReferencesAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken, IReadOnlyDictionary? pushedConnectorIds = null) + public virtual Task ProvisionConnectionReferencesAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken, IReadOnlyDictionary? pushedConnectorIds = null) => Task.CompletedTask; public virtual Task> GetAgentConnectionReferencesAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) @@ -656,6 +694,37 @@ public Task VerifyPushAsync(Microsoft.CopilotStudio.McsC public Task> CloneAllAssetsAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath rootFolder, AgentSyncInfo syncInfo, AssetsToClone assetsToClone, AgentInfo agentInfo, IOperationContextProvider operationContextProvider, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) => Task.FromResult(ImmutableArray.Empty); + + public virtual Task> GetAgentConnectionViewsAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, IConnectionCatalogClient catalogClient, PowerAppsContext catalogContext, CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + + public virtual Task> ApplyConnectionBindingsAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, IConnectionCatalogClient catalogClient, PowerAppsContext catalogContext, IReadOnlyList bindings, CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + + public virtual void WriteConnectionsCache(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, IReadOnlyList views) + { + } + + public virtual long GetConnectionsCacheGeneration(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder) => 0; + + public virtual bool TryWriteConnectionsCache(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, IReadOnlyList views, long expectedGeneration) => true; + + public virtual IReadOnlyList GetWorkflowStatusViews(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, IReadOnlyList views) + => Array.Empty(); + + public virtual Task SetWorkflowActivationsAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, IReadOnlyList requests, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + => Task.FromResult(new WorkflowActivationResult { Succeeded = true }); + + public virtual Task DeclareConnectionReferencesAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, IReadOnlyList logicalNames, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + => Task.FromResult(new DeclareConnectionReferencesResult()); + + public virtual Task CreateConnectionReferenceForConnectorAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, string connectorInternalId, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) + => Task.FromResult(string.Empty); + + public virtual Task RemoveConnectionReferenceAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, string logicalName, bool confirmed, CancellationToken cancellationToken) + => Task.FromResult(new ConnectionReferenceRemovalResult { Removed = true }); + + public virtual ConnectionsCacheFile? ReadConnectionsCache(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder) => null; } internal class TestWorkspaceSynchronizerSyncInfoExists : TestWorkspaceSynchronizer @@ -663,21 +732,33 @@ internal class TestWorkspaceSynchronizerSyncInfoExists : TestWorkspaceSynchroniz public override bool IsSyncInfoAvailable(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder) => true; } - internal class TestWorkspaceSynchronizerRecordingOrder : TestWorkspaceSynchronizer + internal class TestWorkspaceSynchronizerRecordingActivation : TestWorkspaceSynchronizer { - private readonly MockDataverseClient _dataverseClient; + public WorkflowActivationMode? CapturedActivationMode { get; private set; } - public bool UpsertWorkflowInvokedAfterBind { get; private set; } + public override Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken, WorkflowActivationMode activationMode = WorkflowActivationMode.PreserveSavedState) + { + CapturedActivationMode = activationMode; + return base.UpsertWorkflowForAgentAsync(workspaceFolder, dataverseClient, agentId, cancellationToken, activationMode); + } + } + + internal class TestWorkspaceSynchronizerProvisionTracking : TestWorkspaceSynchronizer + { + public bool PushCustomConnectorsCalled { get; private set; } - public TestWorkspaceSynchronizerRecordingOrder(MockDataverseClient dataverseClient) + public bool ProvisionConnectionReferencesCalled { get; private set; } + + public override Task PushCustomConnectorsAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken) { - _dataverseClient = dataverseClient; + PushCustomConnectorsCalled = true; + return base.PushCustomConnectorsAsync(workspaceFolder, dataverseClient, cancellationToken); } - public override Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken) + public override Task ProvisionConnectionReferencesAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, DefinitionBase definition, ISyncDataverseClient dataverseClient, CancellationToken cancellationToken, IReadOnlyDictionary? pushedConnectorIds = null) { - UpsertWorkflowInvokedAfterBind = _dataverseClient.BindConnectionReferenceCalls.Count > 0; - return base.UpsertWorkflowForAgentAsync(workspaceFolder, dataverseClient, agentId, cancellationToken); + ProvisionConnectionReferencesCalled = true; + return Task.CompletedTask; } } @@ -690,7 +771,7 @@ public TestWorkspaceSynchronizerThrowingWorkflow(Exception exception) _exception = exception; } - public override Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken) + public override Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync(Microsoft.CopilotStudio.McsCore.DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken, WorkflowActivationMode activationMode = WorkflowActivationMode.PreserveSavedState) => throw _exception; } @@ -753,11 +834,16 @@ public override Task EnsureConnectionReferenceExistsAsync(string connectionRefer } } - internal class MockDataverseClientThrowingBind : MockDataverseClient + internal class MockDataverseClientWithExistingAgent : MockDataverseClient { - public override Task BindConnectionReferenceAsync(string connectionReferenceLogicalName, string connectionLogicalName, CancellationToken cancellationToken, string? connectionReferenceDisplayName = null) + private readonly Guid _existingAgentId; + + public MockDataverseClientWithExistingAgent(Guid existingAgentId) { - throw new InvalidOperationException("bind failed"); + _existingAgentId = existingAgentId; } + + public override Task GetAgentIdBySchemaNameAsync(string schemaName, CancellationToken cancellationToken) + => Task.FromResult(_existingAgentId); } } diff --git a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/SyncPushHandlerTests.cs b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/SyncPushHandlerTests.cs index 2500d83..db2bfa4 100644 --- a/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/SyncPushHandlerTests.cs +++ b/src/LanguageServers/PowerPlatformLS/UnitTests/PowerPlatformLS.UnitTests/Impl.PullAgent/Methods/SyncPushHandlerTests.cs @@ -1,8 +1,6 @@ namespace Microsoft.PowerPlatformLS.UnitTests.Impl.PullAgent.Methods { - using Microsoft.Agents.ObjectModel; using Microsoft.Agents.Platform.Content; - using Microsoft.CopilotStudio.McsCore; using Microsoft.CopilotStudio.Sync; using Microsoft.CopilotStudio.Sync.Dataverse; using Microsoft.PowerPlatformLS.Contracts.Internal.Models; @@ -125,10 +123,10 @@ internal sealed class PushTrackingSynchronizer : TestWorkspaceSynchronizer public bool PushAttempted { get; private set; } public override Task<(ImmutableArray, CloudFlowMetadata)> UpsertWorkflowForAgentAsync( - DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken) + DirectoryPath workspaceFolder, ISyncDataverseClient dataverseClient, Guid? agentId, CancellationToken cancellationToken, WorkflowActivationMode activationMode = WorkflowActivationMode.PreserveSavedState) { PushAttempted = true; - return base.UpsertWorkflowForAgentAsync(workspaceFolder, dataverseClient, agentId, cancellationToken); + return base.UpsertWorkflowForAgentAsync(workspaceFolder, dataverseClient, agentId, cancellationToken, activationMode); } } } diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/manageConnections.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/manageConnections.ts new file mode 100644 index 0000000..b81e924 --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/manageConnections.ts @@ -0,0 +1,91 @@ +import * as vscode from 'vscode'; +import { CopilotStudioWorkspace, getWorkspaceByUri } from '../sync/localWorkspaces'; +import { selectWorkspace } from '../sync/workspacePicker'; +import { ConnectionManagerController } from '../connections/connectionManager'; +import { declareConnectionReferences } from '../connections/connectionCatalog'; +import { TelemetryEventsKeys } from '../constants'; +import logger from '../services/logger'; + +type ManageConnectionsArg = + | { workspace: CopilotStudioWorkspace } + | { ws: CopilotStudioWorkspace } + | CopilotStudioWorkspace + | undefined; + +const resolveWorkspace = async (arg: ManageConnectionsArg): Promise => { + if (arg && typeof arg === 'object') { + if ('workspace' in arg && arg.workspace) { + return arg.workspace; + } + if ('ws' in arg && arg.ws) { + return arg.ws; + } + if ('workspaceUri' in arg && arg.workspaceUri) { + return arg; + } + } + + const activeUri = vscode.window.activeTextEditor?.document.uri; + if (activeUri) { + const fromActive = getWorkspaceByUri(activeUri); + if (fromActive) { + return fromActive; + } + } + + return selectWorkspace(); +}; + +export const registerManageConnectionsCommand = (context: vscode.ExtensionContext) => { + const command = vscode.commands.registerCommand( + 'microsoft-copilot-studio.manageConnections', + async (arg?: ManageConnectionsArg) => { + const workspace = await resolveWorkspace(arg); + if (!workspace) { + void vscode.window.showInformationMessage('Open or select a connected agent to manage its connections.'); + return; + } + if (!workspace.syncInfo) { + void vscode.window.showWarningMessage('This agent is not connected to an environment. Reattach the agent before managing connections.'); + return; + } + + logger.logInfo(TelemetryEventsKeys.ConnectionCreationInfo); + await ConnectionManagerController.show(context, workspace); + } + ); + + context.subscriptions.push(command); +}; + +export const registerDeclareConnectionReferenceCommand = (context: vscode.ExtensionContext) => { + const command = vscode.commands.registerCommand( + 'microsoft-copilot-studio.declareConnectionReference', + async (logicalName?: string) => { + if (!logicalName || !logicalName.trim()) { + return; + } + const activeUri = vscode.window.activeTextEditor?.document.uri; + const workspace = activeUri ? getWorkspaceByUri(activeUri) : await selectWorkspace(); + if (!workspace?.syncInfo) { + void vscode.window.showWarningMessage('Connect this agent to an environment before declaring connection references.'); + return; + } + + try { + const result = await declareConnectionReferences(workspace, [logicalName.trim()]); + if (result.invalid.length > 0) { + void vscode.window.showErrorMessage(`Couldn't declare connection reference '${logicalName.trim()}'. A connector could not be determined from the name — it must contain a connector segment such as 'shared_office365'.`); + return; + } + void vscode.window.showInformationMessage(`Connection reference '${logicalName.trim()}' declared.`); + } catch (error) { + const message = (error as Error).message ?? 'Failed to declare the connection reference.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to declare connection reference: ${message}`); + void vscode.window.showErrorMessage(message); + } + } + ); + + context.subscriptions.push(command); +}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/reattachAgent.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/reattachAgent.ts index a9c7c3d..d856ef8 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/reattachAgent.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/reattachAgent.ts @@ -1,14 +1,14 @@ import * as vscode from 'vscode'; -import { AccountInfo, ConnectionBinding, EnvironmentInfo, PrepareReattachRequest, PrepareReattachResponse, ReattachAgentRequest, ReattachAgentResponse } from '../types'; +import { AccountInfo, EnvironmentInfo, ReattachAgentRequest, ReattachAgentResponse } from '../types'; import { DefaultCoreServicesClusterCategory, LspMethods, TelemetryEventsKeys } from '../constants'; import { listEnvironmentsAsync } from '../clients/bapClient'; import { hasStoredAccount, switchAccount, getPreferredTreeAccount, listStoredAccounts } from '../clients/account'; import { pushNewWorkspace } from '../sync/workspaceScm'; import { lspClient, buildLspRequestPayload } from '../services/lspClient'; import logger from '../services/logger'; -import { logWorkflowIssues, logAIPromptIssues, withSyncCommandBusy } from '../sync/workspaceSynchronizer'; -import { getActiveAgentAccount, getAllProjectAccounts } from '../sync/localWorkspaces'; -import { createAgentConnections } from '../connections/connectionRepair'; +import { logAIPromptIssues, withSyncCommandBusy } from '../sync/workspaceSynchronizer'; +import { getActiveAgentAccount, getAllProjectAccounts, WorkspaceType, CopilotStudioWorkspace } from '../sync/localWorkspaces'; +import { autoBindAgentConnections, promptManageConnections } from '../connections/connectionManager'; type ReattachEnvironmentPickItem = vscode.QuickPickItem & { environment: EnvironmentInfo; @@ -119,6 +119,8 @@ export const registerReattachAgentCommand = (context: vscode.ExtensionContext) = const workspaceFolder = vscode.workspace.workspaceFolders[0]; const workspaceUri = workspaceFolder.uri.toString() + '/'; // Ensure trailing slash for consistency with workspace cache entries + let workspaceNeedingConnections: CopilotStudioWorkspace | undefined; + await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -131,70 +133,51 @@ export const registerReattachAgentCommand = (context: vscode.ExtensionContext) = const selectedAccount = pickedEnvironment.sourceAccount ?? getPreferredTreeAccount(); const basePayload = await buildLspRequestPayload(undefined, environmentInfo, selectedAccount); - const prepareRequest: PrepareReattachRequest = { + const reattachRequest: ReattachAgentRequest = { ...basePayload, workspaceUri }; - const prepareResult = await lspClient.sendRequest(LspMethods.PREPARE_REATTACH, prepareRequest); - if (prepareResult.code !== 200) { - logger.logError(TelemetryEventsKeys.ReattachAgentError, `Reattach prepare failed: ${prepareResult.message ?? 'Unknown error'}`); + const reattachResult = await lspClient.sendRequest(LspMethods.REATTACH_AGENT, reattachRequest); + if (reattachResult.code !== 200) { + logger.logError(TelemetryEventsKeys.ReattachAgentError, `Reattach failed: ${reattachResult.message ?? 'Unknown error'}`); return; } - let connectionBindings: ConnectionBinding[] = []; - let unfinishedConnections: string[] = []; - try { - if (prepareResult.agentConnections && prepareResult.agentConnections.length > 0) { - const repair = await createAgentConnections( - prepareResult.agentConnections, - environmentInfo, - basePayload.accountInfo.clusterCategory ?? DefaultCoreServicesClusterCategory, - selectedAccount ?? undefined - ); - connectionBindings = repair.bindings; - unfinishedConnections = repair.unfinished; - } - } catch (error) { - logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Error creating agent connections: ${(error as Error).message}`); - unfinishedConnections = prepareResult.agentConnections?.map(c => c.connectionReferenceLogicalName) ?? []; - } - - const finalizeRequest: ReattachAgentRequest = { - ...basePayload, + const reattachedWorkspace: CopilotStudioWorkspace = { workspaceUri, - agentSyncInfo: prepareResult.agentSyncInfo, - connectionBindings, - isNewAgent: prepareResult.isNewAgent, - updateWorkspaceDirectory: prepareResult.updateWorkspaceDirectory + displayName: pickedEnvironment.label || 'Reattached Agent', + description: '', + icon: new vscode.ThemeIcon('symbol-key'), + type: WorkspaceType.Agent, + syncInfo: reattachResult.agentSyncInfo }; - const reattachResult = await lspClient.sendRequest(LspMethods.REATTACH_AGENT, finalizeRequest); - if (reattachResult.code !== 200) { - logger.logError(TelemetryEventsKeys.ReattachAgentError, `Reattach finalize failed: ${reattachResult.message ?? 'Unknown error'}`); - return; - } - - if (unfinishedConnections.length > 0) { - void vscode.window.showWarningMessage( - `The agent was reattached, but these connections still need to be set up before it can run: ${unfinishedConnections.join(', ')}.` - ); - } if (reattachResult.isNewAgent) { - const newWorkspace = { - workspaceUri, - displayName: 'Reattached Agent', - description: '', - icon: new vscode.ThemeIcon('symbol-key'), - type: 0, - syncInfo: reattachResult.agentSyncInfo - }; - await pushNewWorkspace(context, newWorkspace); + await pushNewWorkspace(context, reattachedWorkspace); logger.logInfo(TelemetryEventsKeys.ReattachAgentInfo, `New agent ${reattachResult.agentSyncInfo.agentId} reattached successfully.`); } else { logger.logInfo(TelemetryEventsKeys.ReattachAgentInfo, `Existing agent ${reattachResult.agentSyncInfo.agentId} reattached successfully.`); } - logWorkflowIssues(reattachResult.workflowResponse); + const autoBindResult = await autoBindAgentConnections(reattachedWorkspace, true); + if (autoBindResult.needsNewCount > 0) { + workspaceNeedingConnections = reattachedWorkspace; + } else { + const parts: string[] = []; + if (autoBindResult.boundCount > 0) { + parts.push('Agent connections were bound to existing cloud connections.'); + } + if (autoBindResult.enabledWorkflowCount > 0) { + parts.push(`${autoBindResult.enabledWorkflowCount} workflow${autoBindResult.enabledWorkflowCount === 1 ? ' was' : 's were'} enabled.`); + } + if (parts.length > 0) { + void vscode.window.showInformationMessage(parts.join(' ')); + } + } + + if (autoBindResult.disabledWorkflowNames.length > 0) { + logger.logWarning(TelemetryEventsKeys.ReattachAgentInfo, `These workflows are disabled. Bind their connections, then enable them from the connection manager: ${autoBindResult.disabledWorkflowNames.join(', ')}`); + } logAIPromptIssues(reattachResult.aiPromptResponse); } catch (error) { logger.logError(TelemetryEventsKeys.ReattachAgentError, `Error reattaching agent: ${(error as Error).message}`); @@ -202,6 +185,10 @@ export const registerReattachAgentCommand = (context: vscode.ExtensionContext) = }); } ); + + if (workspaceNeedingConnections) { + await promptManageConnections(context, workspaceNeedingConnections); + } }); quickPick.onDidHide(() => quickPick.dispose()); diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/syncWorkspace.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/syncWorkspace.ts index 1b61ef8..092b318 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/syncWorkspace.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/commands/syncWorkspace.ts @@ -1,7 +1,7 @@ import { commands, DiagnosticSeverity, ExtensionContext, languages, ProgressLocation, RelativePattern, TextDocument, Uri, window, workspace as VSworkspace } from "vscode"; import { CopilotStudioWorkspace, getAllWorkspaces, hasConnectionFileInWorkspace } from "../sync/localWorkspaces"; import { selectWorkspace } from "../sync/workspacePicker"; -import { getOrAddSynchronizer, preparePushConnections, withSyncCommandBusy, WorkspaceSynchronizer } from "../sync/workspaceSynchronizer"; +import { getOrAddSynchronizer, withSyncCommandBusy, WorkspaceSynchronizer } from "../sync/workspaceSynchronizer"; import { registerVirtualKnowledgeProvider } from "../knowledgeFiles/virtualKnowledgeFile"; import { getWorkspaceChanges, refreshAgentChangesAfterFetch } from "../sync/workspaceScm"; import { isKnowledgeFileChangeKind, TelemetryEventsKeys } from "../constants"; @@ -197,22 +197,7 @@ const registerSyncCommand = ( if (errors.count === 0) { const synchronizer = getOrAddSynchronizer(selectedWorkspace); if (id === 'microsoft-copilot-studio.applyChanges') { - const prepared = await preparePushConnections(selectedWorkspace); - if (prepared.status === 'failed') { - await window.showErrorMessage(`Cannot perform ${displayName.toLowerCase()} operation: preparing connections failed.`); - return; - } - if (prepared.status === 'incomplete') { - const proceed = await window.showWarningMessage( - `These connections still need to be set up before the agent can run: ${prepared.unfinished.join(', ')}. Apply changes anyway?`, - { modal: true }, - 'Apply Anyway' - ); - if (proceed !== 'Apply Anyway') { - return; - } - } - await synchronizer.push(false, prepared.bindings); + await synchronizer.push(false, true); } else { await action(synchronizer); } diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/addConnectionReferenceCommand.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/addConnectionReferenceCommand.ts new file mode 100644 index 0000000..58f843e --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/addConnectionReferenceCommand.ts @@ -0,0 +1,197 @@ +import * as vscode from 'vscode'; +import logger from '../services/logger'; +import { DefaultCoreServicesClusterCategory, TelemetryEventsKeys } from '../constants'; +import { CopilotStudioWorkspace, getWorkspaceByUri } from '../sync/localWorkspaces'; +import { AgentConnectionView } from '../types'; +import { + applyConnectionBindings, + createConnectionReference, + declareConnectionReferences, + listAgentConnections +} from './connectionCatalog'; +import { awaitConnectionCreation, resolveCreatedConnectionId } from './connectionCreation'; +import { isCustomConnectorInternalId, waitForCustomConnectorReady } from './connectorReadiness'; +import { pickConnector } from './connectorPicker'; + +export interface AddConnectionReferenceArgs { + documentUri: vscode.Uri; + range: vscode.Range; + currentValue: string; +} + +const parseConnectorInternalId = (value: string): string | undefined => { + if (!value) { + return undefined; + } + for (const segment of value.split('.')) { + if (segment.toLowerCase().startsWith('shared_')) { + return segment; + } + } + return undefined; +}; + +const findView = (views: AgentConnectionView[], logicalName: string): AgentConnectionView | undefined => + views.find(v => v.connectionReferenceLogicalName.toLowerCase() === logicalName.toLowerCase()); + +const writeReferenceIntoDocument = async (documentUri: vscode.Uri, range: vscode.Range, logicalName: string): Promise => { + const edit = new vscode.WorkspaceEdit(); + edit.replace(documentUri, range, logicalName); + await vscode.workspace.applyEdit(edit); + const document = await vscode.workspace.openTextDocument(documentUri); + await document.save(); +}; + +const bindExistingConnection = async (workspace: CopilotStudioWorkspace, view: AgentConnectionView, logicalName: string): Promise => { + const connected = (view.candidates ?? []).filter(c => (c.status || '').toLowerCase() === 'connected'); + if (connected.length === 0) { + void vscode.window.showInformationMessage('No connected connections are available for this connector. Create a new connection instead.'); + return false; + } + + const pick = await vscode.window.showQuickPick( + connected.map(c => ({ + label: c.displayName || c.name, + description: c.owner || undefined, + detail: c.name, + connectionId: c.name, + connectionDisplayName: c.displayName + })), + { title: 'Select a connection to bind', placeHolder: 'Choose an existing connection' } + ); + if (!pick) { + return false; + } + + await applyConnectionBindings(workspace, [ + { + connectionReferenceLogicalName: logicalName, + connectionId: pick.connectionId, + connectionDisplayName: pick.connectionDisplayName || undefined + } + ]); + return true; +}; + +const createAndBindConnection = async (workspace: CopilotStudioWorkspace, connectorInternalId: string, logicalName: string): Promise => { + const syncInfo = workspace.syncInfo; + if (!syncInfo) { + return false; + } + + const tokenSource = new vscode.CancellationTokenSource(); + try { + if (isCustomConnectorInternalId(connectorInternalId)) { + await waitForCustomConnectorReady(workspace, connectorInternalId, { cancellationToken: tokenSource.token }); + } + + const result = await awaitConnectionCreation({ + connectorName: connectorInternalId, + environmentId: syncInfo.environmentId, + clusterCategory: syncInfo.accountInfo.clusterCategory ?? DefaultCoreServicesClusterCategory, + cancellationToken: tokenSource.token + }); + + if (result.status === 'cancelled') { + return false; + } + if (result.status === 'error') { + void vscode.window.showErrorMessage(result.errorMessage ?? 'Connection creation failed.'); + return false; + } + + const connectionId = resolveCreatedConnectionId(result); + if (!connectionId) { + void vscode.window.showErrorMessage('Connection was created but no identifier was returned.'); + return false; + } + + await applyConnectionBindings(workspace, [ + { + connectionReferenceLogicalName: logicalName, + connectionId, + connectionDisplayName: result.displayName || connectorInternalId + } + ]); + return true; + } finally { + tokenSource.dispose(); + } +}; + +export const registerAddConnectionReferenceCommand = (context: vscode.ExtensionContext): void => { + const command = vscode.commands.registerCommand( + 'microsoft-copilot-studio.addConnectionReferenceForDiagnostic', + async (args?: AddConnectionReferenceArgs) => { + if (!args) { + return; + } + + const workspace = getWorkspaceByUri(args.documentUri); + if (!workspace?.syncInfo) { + void vscode.window.showWarningMessage('Connect this agent to an environment before adding connection references.'); + return; + } + + try { + let connectorInternalId = parseConnectorInternalId(args.currentValue); + if (!connectorInternalId) { + connectorInternalId = await pickConnector(workspace); + } + if (!connectorInternalId) { + return; + } + + let logicalName = args.currentValue; + let needsWriteBack = false; + + const declareResult = await declareConnectionReferences(workspace, [args.currentValue]); + if (declareResult.invalid.length > 0) { + const created = await createConnectionReference(workspace, connectorInternalId); + logicalName = created.logicalName; + needsWriteBack = true; + } + + const choice = await vscode.window.showQuickPick( + [ + { label: 'Use an existing connection', action: 'existing' as const }, + { label: 'Create a new connection', action: 'create' as const } + ], + { title: 'Add connection reference', placeHolder: 'Bind the connection reference to a connection' } + ); + if (!choice) { + return; + } + + let bound = false; + if (choice.action === 'existing') { + const views = await listAgentConnections(workspace); + const view = findView(views, logicalName); + if (!view) { + void vscode.window.showErrorMessage('The connection reference could not be found after declaring it.'); + return; + } + bound = await bindExistingConnection(workspace, view, logicalName); + } else { + bound = await createAndBindConnection(workspace, connectorInternalId, logicalName); + } + + if (!bound) { + return; + } + + if (needsWriteBack) { + await writeReferenceIntoDocument(args.documentUri, args.range, logicalName); + } + + void vscode.window.showInformationMessage(`Connection reference '${logicalName}' is ready.`); + } catch (error) { + const message = (error as Error).message ?? 'Failed to add the connection reference.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to add connection reference: ${message}`); + void vscode.window.showErrorMessage(message); + } + } + ); + + context.subscriptions.push(command); +}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionCatalog.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionCatalog.ts new file mode 100644 index 0000000..044afdd --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionCatalog.ts @@ -0,0 +1,226 @@ +import * as vscode from 'vscode'; +import { getAccessTokenByAccountId } from '../clients/account'; +import { getTokenScopeHostName } from '../clients/bapClient'; +import { DefaultCoreServicesClusterCategory, LspMethods } from '../constants'; +import { lspClient, buildLspRequestPayload } from '../services/lspClient'; +import { CopilotStudioWorkspace } from '../sync/localWorkspaces'; +import { + AgentConnectionView, + AgentSyncInfo, + ApplyConnectionBindingsRequest, + ApplyConnectionBindingsResponse, + ConnectionBindingRequest, + ConnectionReferenceUsage, + ConnectorInfo, + CreateConnectionReferenceRequest, + CreateConnectionReferenceResponse, + DeclareConnectionReferencesRequest, + DeclareConnectionReferencesResponse, + ListAgentConnectionsRequest, + ListAgentConnectionsResponse, + ListConnectorsRequest, + ListConnectorsResponse, + ListWorkflowStatusRequest, + ListWorkflowStatusResponse, + RemoveConnectionReferenceRequest, + RemoveConnectionReferenceResponse, + SetWorkflowStatesRequest, + SetWorkflowStatesResponse, + WorkflowStateChange, + WorkflowStatusView +} from '../types'; + +export const acquireConnectionsAccessToken = async (clusterCategory: number, accountId: string | undefined, accountHint: string | undefined): Promise => { + try { + const resource = vscode.Uri.from({ + scheme: 'https', + authority: getTokenScopeHostName(clusterCategory) + }); + const tokenInfo = await getAccessTokenByAccountId(resource, accountId, accountHint); + return tokenInfo.accessToken; + } catch { + return undefined; + } +}; + +const acquireWorkspaceConnectionsToken = (syncInfo: AgentSyncInfo): Promise => + acquireConnectionsAccessToken( + syncInfo.accountInfo.clusterCategory ?? DefaultCoreServicesClusterCategory, + syncInfo.accountInfo.accountId, + syncInfo.accountInfo.accountEmail + ); + +const baseConnectionRequest = async (syncInfo: AgentSyncInfo, workspaceUri: string) => ({ + ...await buildLspRequestPayload(syncInfo), + workspaceUri, + connectionsAccessToken: await acquireWorkspaceConnectionsToken(syncInfo) +}); + +export const listAgentConnections = async (workspace: CopilotStudioWorkspace): Promise => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo) { + return []; + } + + const request: ListAgentConnectionsRequest = { + ...await baseConnectionRequest(syncInfo, workspaceUri) + }; + + const response = await lspClient.sendRequest( + LspMethods.LIST_AGENT_CONNECTIONS, + request + ); + if (response.code !== 200) { + throw new Error(response.message ?? 'Failed to list agent connections.'); + } + return response.agentConnections ?? []; +}; + +export const applyConnectionBindings = async (workspace: CopilotStudioWorkspace, bindings: ConnectionBindingRequest[]): Promise => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo) { + return []; + } + + const request: ApplyConnectionBindingsRequest = { + ...await baseConnectionRequest(syncInfo, workspaceUri), + bindings + }; + + const response = await lspClient.sendRequest( + LspMethods.APPLY_CONNECTION_BINDINGS, + request + ); + if (response.code !== 200) { + throw new Error(response.message ?? 'Failed to apply connection bindings.'); + } + return response.agentConnections ?? []; +}; + +export const listWorkflowStatus = async (workspace: CopilotStudioWorkspace): Promise => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo) { + return []; + } + + const request: ListWorkflowStatusRequest = { + ...await baseConnectionRequest(syncInfo, workspaceUri) + }; + + const response = await lspClient.sendRequest( + LspMethods.LIST_WORKFLOW_STATUS, + request + ); + if (response.code !== 200) { + throw new Error(response.message ?? 'Failed to list workflow status.'); + } + return response.workflows ?? []; +}; + +export const setWorkflowStates = async (workspace: CopilotStudioWorkspace, changes: WorkflowStateChange[]): Promise<{ succeeded: boolean; workflows: WorkflowStatusView[]; message?: string }> => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo || changes.length === 0) { + return { succeeded: true, workflows: [] }; + } + + const request: SetWorkflowStatesRequest = { + ...await baseConnectionRequest(syncInfo, workspaceUri), + changes + }; + + const response = await lspClient.sendRequest( + LspMethods.SET_WORKFLOW_STATES, + request + ); + if (response.code !== 200) { + return { succeeded: false, workflows: response.workflows ?? [], message: response.message }; + } + return { succeeded: response.succeeded, workflows: response.workflows ?? [], message: response.message }; +}; + +export const declareConnectionReferences = async (workspace: CopilotStudioWorkspace, logicalNames: string[]): Promise<{ views: AgentConnectionView[]; invalid: string[] }> => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo) { + return { views: [], invalid: [] }; + } + + const request: DeclareConnectionReferencesRequest = { + ...await baseConnectionRequest(syncInfo, workspaceUri), + logicalNames + }; + + const response = await lspClient.sendRequest( + LspMethods.DECLARE_CONNECTION_REFERENCES, + request + ); + if (response.code !== 200) { + throw new Error(response.message ?? 'Failed to declare connection references.'); + } + return { views: response.agentConnections ?? [], invalid: response.invalidLogicalNames ?? [] }; +}; + +export const removeConnectionReference = async (workspace: CopilotStudioWorkspace, logicalName: string, confirmed: boolean): Promise<{ removed: boolean; usages: ConnectionReferenceUsage[]; message?: string }> => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo) { + return { removed: false, usages: [] }; + } + + const basePayload = await buildLspRequestPayload(syncInfo); + + const request: RemoveConnectionReferenceRequest = { + ...basePayload, + workspaceUri, + logicalName, + confirmed + }; + + const response = await lspClient.sendRequest( + LspMethods.REMOVE_CONNECTION_REFERENCE, + request + ); + if (response.code !== 200) { + return { removed: false, usages: response.usages ?? [], message: response.message }; + } + return { removed: response.removed, usages: response.usages ?? [], message: response.message }; +}; + +export const listConnectors = async (workspace: CopilotStudioWorkspace): Promise => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo) { + return []; + } + + const request: ListConnectorsRequest = { + ...await baseConnectionRequest(syncInfo, workspaceUri) + }; + + const response = await lspClient.sendRequest( + LspMethods.LIST_CONNECTORS, + request + ); + if (response.code !== 200) { + throw new Error(response.message ?? 'Failed to list connectors.'); + } + return response.connectors ?? []; +}; + +export const createConnectionReference = async (workspace: CopilotStudioWorkspace, connectorInternalId: string): Promise<{ logicalName: string; views: AgentConnectionView[] }> => { + const { syncInfo, workspaceUri } = workspace; + if (!syncInfo) { + return { logicalName: '', views: [] }; + } + + const request: CreateConnectionReferenceRequest = { + ...await baseConnectionRequest(syncInfo, workspaceUri), + connectorInternalId + }; + + const response = await lspClient.sendRequest( + LspMethods.CREATE_CONNECTION_REFERENCE, + request + ); + if (response.code !== 200) { + throw new Error(response.message ?? 'Failed to create the connection reference.'); + } + return { logicalName: response.logicalName, views: response.agentConnections ?? [] }; +}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionCreation.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionCreation.ts index 25536ed..a7b6528 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionCreation.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionCreation.ts @@ -12,6 +12,19 @@ export type ConnectionCreationResult = | { status: 'cancelled' } | { status: 'error'; errorMessage?: string }; +export const resolveCreatedConnectionId = (result: { connectionName?: string; connectionId?: string }): string | undefined => { + const name = result.connectionName?.trim(); + if (name) { + return name; + } + const id = result.connectionId?.trim(); + if (!id) { + return undefined; + } + const segments = id.split('/').filter(Boolean); + return segments.length ? segments[segments.length - 1] : id; +}; + export interface ConnectionCreationOptions { connectorName: string; environmentId: string; @@ -52,13 +65,7 @@ const normalizeConnector = (connector: string): string => { return trimmed.includes('/') ? trimmed.substring(trimmed.lastIndexOf('/') + 1) : trimmed; }; -const buildPlayerUrl = ( - clusterCategory: CoreServicesClusterCategory, - environmentId: string, - connector: string, - callbackUrl: string, - nonce: string -): string => { +const buildPlayerUrl = (clusterCategory: CoreServicesClusterCategory, environmentId: string, connector: string, callbackUrl: string, nonce: string): string => { const url = new URL(`/appframework/e/${environmentId}/connections/new`, getPlayerBaseUrl(clusterCategory)); url.searchParams.set('connector', connector); url.searchParams.set('callbackUrl', callbackUrl); @@ -71,12 +78,7 @@ const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, c => `&#${c.char const renderResultPage = (status: string | null, message?: string): string => { const safeMessage = message ? escapeHtml(message) : ''; - const heading = - status === 'created' - ? 'Connection created. You can close this tab and return to VS Code.' - : status === 'error' - ? 'Connection creation failed.' - : 'Connection creation was cancelled.'; + const heading = status === 'created' ? 'Connection created. You can close this tab and return to VS Code.' : status === 'error' ? 'Connection creation failed.' : 'Connection creation was cancelled.'; return `Copilot Studio` + `

${escapeHtml(heading)}

` + (safeMessage ? `

${safeMessage}

` : '') diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionDiagnostics.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionDiagnostics.ts new file mode 100644 index 0000000..c76e67a --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionDiagnostics.ts @@ -0,0 +1,59 @@ +import * as vscode from 'vscode'; + +const CONNECTION_REFERENCE_DIAGNOSTIC_CODES = new Set([ + 'UnknownConnectionReference', + 'UndeclaredConnectionReference', + 'UnboundConnectionReference' +]); + +const isConnectionReferenceDiagnostic = (diagnostic: vscode.Diagnostic): boolean => { + const code = typeof diagnostic.code === 'object' && diagnostic.code !== null ? diagnostic.code.value : diagnostic.code; + return typeof code === 'string' && CONNECTION_REFERENCE_DIAGNOSTIC_CODES.has(code); +}; + +class ConnectionReferenceCodeActionProvider implements vscode.CodeActionProvider { + public static readonly providedKinds = [vscode.CodeActionKind.QuickFix]; + + public provideCodeActions(document: vscode.TextDocument, _range: vscode.Range | vscode.Selection, context: vscode.CodeActionContext): vscode.CodeAction[] { + const relevant = context.diagnostics.filter(isConnectionReferenceDiagnostic); + if (!relevant.length) { + return []; + } + + const diagnostic = relevant[0]; + const logicalName = extractLogicalName(document, diagnostic); + + const manage = new vscode.CodeAction('Manage connections…', vscode.CodeActionKind.QuickFix); + manage.command = { + command: 'microsoft-copilot-studio.manageConnections', + title: 'Manage connections', + arguments: logicalName ? [{ connectionReferenceLogicalName: logicalName }] : undefined + }; + manage.diagnostics = relevant; + + const add = new vscode.CodeAction('Add new connection reference…', vscode.CodeActionKind.QuickFix); + add.command = { + command: 'microsoft-copilot-studio.addConnectionReferenceForDiagnostic', + title: 'Add new connection reference', + arguments: [{ documentUri: document.uri, range: diagnostic.range, currentValue: logicalName ?? '' }] + }; + add.diagnostics = relevant; + add.isPreferred = true; + + return [add, manage]; + } +} + +const extractLogicalName = (document: vscode.TextDocument, diagnostic: vscode.Diagnostic): string | undefined => { + const text = document.getText(diagnostic.range).trim(); + return text.length > 0 ? text : undefined; +}; + +export const registerConnectionReferenceQuickFix = (context: vscode.ExtensionContext): void => { + const provider = vscode.languages.registerCodeActionsProvider( + { language: 'CopilotStudio' }, + new ConnectionReferenceCodeActionProvider(), + { providedCodeActionKinds: ConnectionReferenceCodeActionProvider.providedKinds } + ); + context.subscriptions.push(provider); +}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionExistence.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionExistence.ts deleted file mode 100644 index b0c11c4..0000000 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionExistence.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { CancellationToken, Uri } from 'vscode'; -import { FetchAccessToken } from '../clients/account'; -import { getTokenScopeHostName } from '../clients/bapClient'; -import { CoreServicesClusterCategory, TelemetryEventsKeys } from '../constants'; -import logger from '../services/logger'; - -const CONNECTIONS_API_VERSION = '2016-11-01'; - -const getConnectionsApiHost = (clusterCategory: CoreServicesClusterCategory): string => { - switch (clusterCategory) { - case CoreServicesClusterCategory.Gov: - case CoreServicesClusterCategory.GovFR: - return 'gov.api.powerapps.us'; - case CoreServicesClusterCategory.High: - return 'high.api.powerapps.us'; - case CoreServicesClusterCategory.DoD: - return 'api.apps.appsplatform.us'; - case CoreServicesClusterCategory.Mooncake: - return 'api.powerapps.cn'; - case CoreServicesClusterCategory.Ex: - return 'api.powerapps.eaglex.ic.gov'; - case CoreServicesClusterCategory.Rx: - return 'api.powerapps.microsoft.scloud'; - case CoreServicesClusterCategory.Exp: - case CoreServicesClusterCategory.Dev: - case CoreServicesClusterCategory.Test: - case CoreServicesClusterCategory.Preprod: - case CoreServicesClusterCategory.Prv: - case CoreServicesClusterCategory.Prod: - case CoreServicesClusterCategory.FirstRelease: - default: - return 'api.powerapps.com'; - } -}; - -const lastSegment = (value: string): string => { - const trimmed = value.trim().replace(/\/+$/, ''); - return trimmed.includes('/') ? trimmed.substring(trimmed.lastIndexOf('/') + 1) : trimmed; -}; - -const escapeODataString = (value: string): string => value.replace(/'/g, "''"); - -interface ConnectionListItem { - name?: string; -} - -interface ConnectionListResponse { - value?: ConnectionListItem[]; -} - -export interface ConnectionExistenceOptions { - connectorName: string; - connectionId: string; - environmentId: string; - clusterCategory: CoreServicesClusterCategory; - accountId: string | null; - accountHint?: string; -} - -export const connectionExists = async ( - options: ConnectionExistenceOptions -): Promise => { - const { connectorName, connectionId, environmentId, clusterCategory, accountId, accountHint } = options; - - const normalizedConnector = lastSegment(connectorName); - const targetName = lastSegment(connectionId); - if (!normalizedConnector || !targetName) { - return undefined; - } - - const filter = `environment eq '${escapeODataString(environmentId)}'`; - const requestUri = Uri.from({ - scheme: 'https', - authority: getConnectionsApiHost(clusterCategory), - path: `/providers/Microsoft.PowerApps/apis/${normalizedConnector}/connections`, - query: `api-version=${CONNECTIONS_API_VERSION}&$filter=${encodeURIComponent(filter)}` - }); - - const resource = Uri.from({ - scheme: 'https', - authority: getTokenScopeHostName(clusterCategory) - }); - - try { - const { response } = await FetchAccessToken(resource, requestUri, accountId, null, accountId === null, accountHint); - if (!response.ok) { - logger.logInfo( - TelemetryEventsKeys.ConnectionCreationInfo, - `Connection existence check returned status ${response.status} for connector ${normalizedConnector}.` - ); - return undefined; - } - - const body = (await response.json()) as ConnectionListResponse; - const connections = body.value ?? []; - return connections.some((c) => (c.name ?? '').localeCompare(targetName, undefined, { sensitivity: 'accent' }) === 0); - } catch (error) { - logger.logInfo( - TelemetryEventsKeys.ConnectionCreationInfo, - `Connection existence check failed for connector ${normalizedConnector}: ${(error as Error).message}` - ); - return undefined; - } -}; - -const CUSTOM_CONNECTOR_INTERNAL_ID_REGEX = /-5f[0-9a-f]{16}$/i; - -export const isCustomConnectorInternalId = (connectorName: string): boolean => { - return CUSTOM_CONNECTOR_INTERNAL_ID_REGEX.test(lastSegment(connectorName)); -}; - -export interface ConnectorAvailabilityOptions { - connectorName: string; - environmentId: string; - clusterCategory: CoreServicesClusterCategory; - accountId: string | null; - accountHint?: string; -} - -const connectorAvailable = async ( - options: ConnectorAvailabilityOptions -): Promise => { - const { connectorName, environmentId, clusterCategory, accountId, accountHint } = options; - - const normalizedConnector = lastSegment(connectorName); - if (!normalizedConnector) { - return undefined; - } - - const filter = `environment eq '${escapeODataString(environmentId)}'`; - const requestUri = Uri.from({ - scheme: 'https', - authority: getConnectionsApiHost(clusterCategory), - path: `/providers/Microsoft.PowerApps/apis/${normalizedConnector}/connections`, - query: `api-version=${CONNECTIONS_API_VERSION}&$filter=${encodeURIComponent(filter)}` - }); - - const resource = Uri.from({ - scheme: 'https', - authority: getTokenScopeHostName(clusterCategory) - }); - - try { - const { response } = await FetchAccessToken(resource, requestUri, accountId, null, accountId === null, accountHint); - if (response.ok) { - return true; - } - if (response.status === 404) { - return false; - } - return undefined; - } catch (error) { - logger.logInfo( - TelemetryEventsKeys.ConnectionCreationInfo, - `Connector availability check failed for connector ${normalizedConnector}: ${(error as Error).message}` - ); - return undefined; - } -}; - -export interface WaitForConnectorAvailableOptions extends ConnectorAvailabilityOptions { - timeoutMs?: number; - cancellationToken?: CancellationToken; -} - -const delay = (ms: number, cancellationToken?: CancellationToken): Promise => - new Promise((resolve) => { - let listener: { dispose(): void } | undefined; - const timer = setTimeout(() => { - listener?.dispose(); - resolve(); - }, ms); - listener = cancellationToken?.onCancellationRequested(() => { - clearTimeout(timer); - listener?.dispose(); - resolve(); - }); - }); - -export const waitForConnectorAvailable = async ( - options: WaitForConnectorAvailableOptions -): Promise => { - const timeoutMs = options.timeoutMs ?? 30_000; - const normalizedConnector = lastSegment(options.connectorName); - const deadline = Date.now() + timeoutMs; - let nextDelayMs = 1_000; - const maxDelayMs = 3_000; - - while (!options.cancellationToken?.isCancellationRequested) { - const available = await connectorAvailable(options); - if (available === true) { - return true; - } - - if (Date.now() >= deadline) { - logger.logInfo( - TelemetryEventsKeys.ConnectionCreationInfo, - `Connector ${normalizedConnector} was not available in the API Hub within ${timeoutMs} ms; proceeding anyway.` - ); - return false; - } - - await delay(Math.min(nextDelayMs, Math.max(0, deadline - Date.now())), options.cancellationToken); - nextDelayMs = Math.min(Math.floor(nextDelayMs * 1.5), maxDelayMs); - } - - return false; -}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionManager.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionManager.ts new file mode 100644 index 0000000..14ea78e --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionManager.ts @@ -0,0 +1,493 @@ +import * as vscode from 'vscode'; +import logger from '../services/logger'; +import { DefaultCoreServicesClusterCategory, TelemetryEventsKeys } from '../constants'; +import { CopilotStudioWorkspace } from '../sync/localWorkspaces'; +import { ConnectionBindingRequest, ConnectionReferenceUsage, AgentConnectionView, WorkflowState, WorkflowStatusView } from '../types'; +import { + applyConnectionBindings, + createConnectionReference, + declareConnectionReferences, + listAgentConnections, + listWorkflowStatus, + removeConnectionReference, + setWorkflowStates +} from './connectionCatalog'; +import { awaitConnectionCreation, resolveCreatedConnectionId } from './connectionCreation'; +import { isCustomConnectorInternalId, waitForCustomConnectorReady } from './connectorReadiness'; +import { pickConnector } from './connectorPicker'; +import { buildConnectionManagerHtml } from './connectionManagerHtml'; + +interface ReadyMessage { type: 'ready'; } +interface RefreshMessage { type: 'refresh'; } +interface CreateConnectionRequest { connectionReferenceLogicalName: string; connectorName: string; } +interface ApplyMessage { type: 'apply'; bindings: ConnectionBindingRequest[]; creates: CreateConnectionRequest[]; } +interface EnableWorkflowMessage { type: 'enableWorkflow'; workflowId: string; activate: boolean; } +interface AddReferenceMessage { type: 'addReference'; } +interface DeclareReferenceMessage { type: 'declareReference'; logicalName: string; } +interface DeleteReferenceMessage { type: 'deleteReference'; logicalName: string; } +interface OpenUsageMessage { type: 'openUsage'; filePath: string; } + +type WebviewMessage = + | ReadyMessage + | RefreshMessage + | ApplyMessage + | EnableWorkflowMessage + | AddReferenceMessage + | DeclareReferenceMessage + | DeleteReferenceMessage + | OpenUsageMessage; + +export class ConnectionManagerController { + private static readonly panels = new Map(); + + public static async show(context: vscode.ExtensionContext, workspace: CopilotStudioWorkspace): Promise { + const key = workspace.workspaceUri; + const existing = ConnectionManagerController.panels.get(key); + if (existing) { + existing.panel.reveal(vscode.ViewColumn.Active); + return; + } + const controller = new ConnectionManagerController(context, workspace, buildConnectionManagerHtml); + ConnectionManagerController.panels.set(key, controller); + } + + private readonly panel: vscode.WebviewPanel; + private readonly disposables: vscode.Disposable[] = []; + private currentViews: AgentConnectionView[] = []; + + private constructor( + private readonly context: vscode.ExtensionContext, + private readonly workspace: CopilotStudioWorkspace, + buildHtml: (webview: vscode.Webview, agentName: string) => string + ) { + this.panel = vscode.window.createWebviewPanel( + 'copilotStudioConnectionManager', + `Connections: ${workspace.displayName}`, + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri] + } + ); + + this.panel.webview.html = buildHtml(this.panel.webview, workspace.displayName); + this.disposables.push( + this.panel.webview.onDidReceiveMessage((msg: WebviewMessage) => void this.onMessage(msg)) + ); + this.panel.onDidDispose(() => this.dispose(), undefined, this.disposables); + } + + private async onMessage(msg: WebviewMessage): Promise { + switch (msg.type) { + case 'ready': + case 'refresh': + await this.loadAndPost(); + return; + case 'apply': + await this.applyBindings(msg.bindings, msg.creates); + return; + case 'enableWorkflow': + await this.enableWorkflow(msg.workflowId, msg.activate); + return; + case 'addReference': + await this.addReference(); + return; + case 'declareReference': + await this.declareReference(msg.logicalName); + return; + case 'deleteReference': + await this.deleteReference(msg.logicalName); + return; + case 'openUsage': + await this.openUsage(msg.filePath); + return; + } + } + + private async loadWorkflowStatus(): Promise { + try { + return await listWorkflowStatus(this.workspace); + } catch (workflowError) { + const message = (workflowError as Error).message ?? 'Failed to load workflow status.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to load workflow status: ${message}`); + return []; + } + } + + private async loadAndPost(): Promise { + this.post({ type: 'busy', busy: true, message: 'Loading connections…' }); + try { + const views = await listAgentConnections(this.workspace); + this.currentViews = views; + const workflows = await this.loadWorkflowStatus(); + this.post({ type: 'data', views, workflows }); + } catch (error) { + this.reportError(error, 'Failed to load connections.', 'Failed to load agent connections'); + } + } + + private async enableWorkflow(workflowId: string, activate: boolean): Promise { + this.post({ type: 'busy', busy: true, message: activate ? 'Enabling workflow…' : 'Disabling workflow…' }); + try { + const result = await setWorkflowStates(this.workspace, [{ workflowId, activate }]); + const updated = result.workflows.find(w => w.workflowId === workflowId); + const isActivated = updated?.state === WorkflowState.Activated; + if (!result.succeeded || (activate && !isActivated)) { + await this.loadAndPost(); + this.post({ type: 'warning', message: result.message ?? 'The workflow could not be enabled and was kept as a draft.' }); + return; + } + void vscode.window.showInformationMessage(activate ? 'Workflow enabled.' : 'Workflow disabled.'); + await this.loadAndPost(); + } catch (error) { + this.reportError(error, 'Failed to update the workflow.', 'Failed to set workflow state'); + } + } + + private async addReference(): Promise { + const usedConnectorIds = new Set( + this.currentViews.map(v => v.connectorName).filter((name): name is string => !!name) + ); + const connectorInternalId = await pickConnector(this.workspace, usedConnectorIds); + if (!connectorInternalId) { + return; + } + this.post({ type: 'busy', busy: true, message: 'Creating connection reference…' }); + try { + const result = await createConnectionReference(this.workspace, connectorInternalId); + void vscode.window.showInformationMessage(`Connection reference '${result.logicalName}' created.`); + await this.loadAndPost(); + } catch (error) { + this.reportError(error, 'Failed to create the connection reference.', 'Failed to create connection reference'); + } + } + + private async declareReference(logicalName: string): Promise { + const trimmed = logicalName.trim(); + if (!trimmed) { + return; + } + this.post({ type: 'busy', busy: true, message: 'Declaring connection reference…' }); + try { + const result = await declareConnectionReferences(this.workspace, [trimmed]); + if (result.invalid.length > 0) { + this.post({ type: 'error', message: `Couldn't declare connection reference '${trimmed}'. A connector could not be determined from the name.` }); + await this.loadAndPost(); + return; + } + void vscode.window.showInformationMessage(`Connection reference '${trimmed}' declared.`); + await this.loadAndPost(); + } catch (error) { + this.reportError(error, 'Failed to declare the connection reference.', 'Failed to declare connection reference'); + } + } + + private async deleteReference(logicalName: string): Promise { + this.post({ type: 'busy', busy: true, message: 'Removing connection reference…' }); + try { + const first = await removeConnectionReference(this.workspace, logicalName, false); + if (first.removed) { + void vscode.window.showInformationMessage(`Connection reference '${logicalName}' removed.`); + await this.loadAndPost(); + return; + } + if (first.usages.length === 0) { + this.post({ type: 'error', message: first.message ?? 'Failed to remove the connection reference.' }); + return; + } + const confirmed = await this.confirmReferenceRemoval(logicalName, first.usages); + if (!confirmed) { + await this.loadAndPost(); + return; + } + this.post({ type: 'busy', busy: true, message: 'Removing connection reference…' }); + const second = await removeConnectionReference(this.workspace, logicalName, true); + if (second.removed) { + void vscode.window.showInformationMessage(`Connection reference '${logicalName}' removed.`); + } else { + this.post({ type: 'error', message: second.message ?? 'Failed to remove the connection reference.' }); + } + await this.loadAndPost(); + } catch (error) { + this.reportError(error, 'Failed to remove the connection reference.', 'Failed to remove connection reference'); + } + } + + private async confirmReferenceRemoval(logicalName: string, usages: ConnectionReferenceUsage[]): Promise { + const locations = usages.map(u => u.displayName || u.filePath).filter(Boolean); + const preview = locations.slice(0, 5).join(', '); + const more = locations.length > 5 ? `, and ${locations.length - 5} more` : ''; + const detail = locations.length > 0 ? `It is still used by: ${preview}${more}.` : 'It is still used in this agent.'; + const remove = 'Remove anyway'; + const choice = await vscode.window.showWarningMessage( + `Connection reference '${logicalName}' is still in use. ${detail}`, + { modal: true }, + remove + ); + return choice === remove; + } + + private async openUsage(filePath: string): Promise { + if (!filePath) { + return; + } + try { + const base = vscode.Uri.parse(this.workspace.workspaceUri); + let target = vscode.Uri.joinPath(base, filePath); + if (/workflow\.json$/i.test(filePath)) { + try { + await vscode.workspace.fs.stat(target); + } catch { + target = vscode.Uri.joinPath(base, filePath.replace(/workflow\.json$/i, 'metadata.yml')); + } + } + await vscode.window.showTextDocument(target, { preview: true }); + } catch (error) { + const message = (error as Error).message ?? 'Failed to open the file.'; + this.post({ type: 'error', message }); + } + } + + private async applyBindings(bindings: ConnectionBindingRequest[], creates: CreateConnectionRequest[] = []): Promise { + if (!bindings.length && !creates.length) { + return; + } + + const allBindings = [...bindings]; + + const createsByConnector = new Map(); + for (const create of creates) { + const list = createsByConnector.get(create.connectorName) ?? []; + list.push(create); + createsByConnector.set(create.connectorName, list); + } + + for (const [connectorName, requests] of createsByConnector) { + const created = await this.createConnection(requests[0].connectionReferenceLogicalName, connectorName); + if (!created) { + continue; + } + for (const request of requests) { + allBindings.push({ + connectionReferenceLogicalName: request.connectionReferenceLogicalName, + connectionId: created.connectionId, + connectionDisplayName: created.connectionDisplayName + }); + } + } + + if (!allBindings.length) { + await this.loadAndPost(); + return; + } + + this.post({ type: 'busy', busy: true, message: 'Applying connection bindings…' }); + try { + const views = await applyConnectionBindings(this.workspace, allBindings); + this.currentViews = views; + void vscode.window.showInformationMessage('Connection bindings updated.'); + const workflows = await this.loadWorkflowStatus(); + this.post({ type: 'data', views, workflows }); + } catch (error) { + this.reportError(error, 'Failed to apply connection bindings.', 'Failed to apply connection bindings'); + } + } + + private async createConnection(connectionReferenceLogicalName: string, connectorName: string): Promise { + const syncInfo = this.workspace.syncInfo; + if (!syncInfo) { + return undefined; + } + + const clusterCategory = syncInfo.accountInfo.clusterCategory ?? DefaultCoreServicesClusterCategory; + + this.post({ type: 'busy', busy: true, message: `Complete the new connection for '${connectionReferenceLogicalName}' in your browser…` }); + + const tokenSource = new vscode.CancellationTokenSource(); + try { + if (isCustomConnectorInternalId(connectorName)) { + this.post({ type: 'busy', busy: true, message: `Waiting for the custom connector for '${connectionReferenceLogicalName}' to become ready…` }); + await waitForCustomConnectorReady(this.workspace, connectorName, { cancellationToken: tokenSource.token }); + this.post({ type: 'busy', busy: true, message: `Complete the new connection for '${connectionReferenceLogicalName}' in your browser…` }); + } + + const result = await awaitConnectionCreation({ + connectorName, + environmentId: syncInfo.environmentId, + clusterCategory, + cancellationToken: tokenSource.token + }); + + if (result.status === 'cancelled') { + this.post({ type: 'info', message: 'Connection creation was cancelled.' }); + return undefined; + } + if (result.status === 'error') { + const message = result.errorMessage ?? 'Connection creation failed.'; + this.post({ type: 'error', message }); + return undefined; + } + + const connectionId = resolveCreatedConnectionId(result); + if (!connectionId) { + this.post({ type: 'error', message: 'Connection was created but no identifier was returned.' }); + return undefined; + } + + return { + connectionReferenceLogicalName, + connectionId, + connectionDisplayName: result.displayName || connectorName + }; + } finally { + tokenSource.dispose(); + } + } + + private reportError(error: unknown, fallback: string, logLabel: string): void { + const message = (error as Error).message ?? fallback; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `${logLabel}: ${message}`); + this.post({ type: 'error', message }); + } + + private post(message: unknown): void { + void this.panel.webview.postMessage(message); + } + + private dispose(): void { + ConnectionManagerController.panels.delete(this.workspace.workspaceUri); + while (this.disposables.length) { + this.disposables.pop()?.dispose(); + } + } +} + +export interface AutoBindResult { + boundCount: number; + needsNewCount: number; + enabledWorkflowCount: number; + disabledWorkflowNames: string[]; +} + +export const autoBindAgentConnections = async (workspace: CopilotStudioWorkspace, enableEligibleWorkflows = false): Promise => { + let views: AgentConnectionView[]; + try { + views = await listAgentConnections(workspace); + } catch (error) { + const message = (error as Error).message ?? 'Failed to list agent connections.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to auto-bind connections: ${message}`); + return { boundCount: 0, needsNewCount: 0, enabledWorkflowCount: 0, disabledWorkflowNames: [] }; + } + + const bindings: ConnectionBindingRequest[] = []; + let needsNewCount = 0; + for (const view of views) { + if (view.boundConnectionExists) { + continue; + } + const connectedCandidates = view.candidates.filter(c => (c.status || '').toLowerCase() === 'connected'); + if (connectedCandidates.length !== 1) { + needsNewCount++; + continue; + } + const candidate = connectedCandidates[0]; + bindings.push({ + connectionReferenceLogicalName: view.connectionReferenceLogicalName, + connectionId: candidate.name, + connectionDisplayName: candidate.displayName || candidate.name + }); + } + + if (bindings.length) { + try { + await applyConnectionBindings(workspace, bindings); + } catch (error) { + const message = (error as Error).message ?? 'Failed to bind existing connections.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to auto-bind connections: ${message}`); + return { boundCount: 0, needsNewCount, enabledWorkflowCount: 0, disabledWorkflowNames: [] }; + } + } + + const { enabledCount, disabledWorkflowNames } = enableEligibleWorkflows + ? await enableEligibleDraftWorkflows(workspace) + : { enabledCount: 0, disabledWorkflowNames: await listDisabledWorkflowNames(workspace) }; + + return { + boundCount: bindings.length, + needsNewCount, + enabledWorkflowCount: enabledCount, + disabledWorkflowNames + }; +}; + +const listDisabledWorkflowNames = async (workspace: CopilotStudioWorkspace): Promise => { + let workflows: WorkflowStatusView[]; + try { + workflows = await listWorkflowStatus(workspace); + } catch (error) { + const message = (error as Error).message ?? 'Failed to list workflow status.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to list workflow status: ${message}`); + return []; + } + + return workflows + .filter(workflow => workflow.state !== WorkflowState.Activated) + .map(workflow => workflow.displayName); +}; + +const enableEligibleDraftWorkflows = async (workspace: CopilotStudioWorkspace): Promise<{ enabledCount: number; disabledWorkflowNames: string[] }> => { + let workflows: WorkflowStatusView[]; + try { + workflows = await listWorkflowStatus(workspace); + } catch (error) { + const message = (error as Error).message ?? 'Failed to list workflow status.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to enable workflows: ${message}`); + return { enabledCount: 0, disabledWorkflowNames: [] }; + } + + const toEnable = workflows.filter(w => w.canEnable && w.state !== WorkflowState.Activated); + const disabledWorkflowNames = workflows + .filter(w => !w.canEnable && w.state !== WorkflowState.Activated) + .map(w => w.displayName); + + if (toEnable.length === 0) { + return { enabledCount: 0, disabledWorkflowNames }; + } + + let refreshed: WorkflowStatusView[]; + try { + const result = await setWorkflowStates(workspace, toEnable.map(w => ({ workflowId: w.workflowId, activate: true }))); + refreshed = result.workflows; + } catch (error) { + const message = (error as Error).message ?? 'Failed to enable the workflows.'; + logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Failed to enable workflows: ${message}`); + return { enabledCount: 0, disabledWorkflowNames: [...disabledWorkflowNames, ...toEnable.map(w => w.displayName)] }; + } + + const refreshedById = new Map(refreshed.map(w => [w.workflowId, w])); + let enabledCount = 0; + for (const workflow of toEnable) { + const updated = refreshedById.get(workflow.workflowId); + if (updated && updated.state === WorkflowState.Activated) { + enabledCount++; + } else { + disabledWorkflowNames.push(workflow.displayName); + } + } + + return { enabledCount, disabledWorkflowNames }; +}; + +export const promptManageConnections = async (context: vscode.ExtensionContext, workspace: CopilotStudioWorkspace): Promise => { + const manageNow = 'Manage now'; + const choice = await vscode.window.showInformationMessage( + 'This agent has connections that still need to be set up before it can run.', + { modal: true }, + manageNow + ); + if (choice === manageNow) { + await ConnectionManagerController.show(context, workspace); + } +}; + diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionManagerHtml.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionManagerHtml.ts new file mode 100644 index 0000000..be060a9 --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionManagerHtml.ts @@ -0,0 +1,895 @@ +import * as vscode from 'vscode'; + +function getNonce(): string { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + +export function buildConnectionManagerHtml(webview: vscode.Webview, agentName: string): string { + const nonce = getNonce(); + const csp = [ + `default-src 'none'`, + `img-src ${webview.cspSource} https: data:`, + `style-src 'nonce-${nonce}'`, + `script-src 'nonce-${nonce}'` + ].join('; '); + + const title = escapeHtml(agentName); + + return ` + + + + + + Connections + + + +

Connections

+

${title}

+
+ + + + +
+ +
+
+ +
+ + +`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionRepair.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionRepair.ts deleted file mode 100644 index ca7e563..0000000 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectionRepair.ts +++ /dev/null @@ -1,192 +0,0 @@ -import * as vscode from 'vscode'; -import { ConnectionBinding, ConnectionNeeded, EnvironmentInfo } from '../types'; -import { CoreServicesClusterCategory, TelemetryEventsKeys } from '../constants'; -import logger from '../services/logger'; -import { awaitConnectionCreation } from './connectionCreation'; -import { connectionExists, isCustomConnectorInternalId, waitForConnectorAvailable } from './connectionExistence'; - -export type ConnectionRepairAccount = { accountId?: string; accountEmail?: string }; - -export interface ConnectionRepairResult { - bindings: ConnectionBinding[]; - unfinished: string[]; -} - -const resolveConnectionLogicalName = (connectionName?: string, connectionId?: string): string => { - if (connectionName) { - return connectionName; - } - if (connectionId) { - const segments = connectionId.split('/').filter(s => s.length > 0); - if (segments.length > 0) { - return segments[segments.length - 1]; - } - } - return ''; -}; - -const resolveConnectionsNeeding = async ( - agentConnections: ConnectionNeeded[], - environmentInfo: EnvironmentInfo, - clusterCategory: CoreServicesClusterCategory, - account: ConnectionRepairAccount | undefined -): Promise<{ needed: ConnectionNeeded[]; unverified: ConnectionNeeded[] }> => { - const needed: ConnectionNeeded[] = []; - const unverified: ConnectionNeeded[] = []; - for (const connection of agentConnections) { - if (!connection.boundConnectionId) { - needed.push(connection); - continue; - } - - const exists = await connectionExists({ - connectorName: connection.connectorName || connection.connectorId, - connectionId: connection.boundConnectionId, - environmentId: environmentInfo.environmentId, - clusterCategory, - accountId: account?.accountId ?? null, - accountHint: account?.accountEmail - }); - - if (exists === false) { - needed.push(connection); - } else if (exists === undefined) { - unverified.push(connection); - } - } - return { needed, unverified }; -}; - -const createConnections = async ( - connectionsNeeded: ConnectionNeeded[], - environmentInfo: EnvironmentInfo, - clusterCategory: CoreServicesClusterCategory -): Promise => { - const count = connectionsNeeded.length; - if (count === 0) { - return { bindings: [], unfinished: [] }; - } - - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Creating connections', - cancellable: true - }, - async (progress, token) => { - const bindings: ConnectionBinding[] = []; - const unfinished: string[] = []; - - for (let i = 0; i < connectionsNeeded.length; i++) { - const connection = connectionsNeeded[i]; - const label = connection.connectorName || connection.connectorId || connection.connectionReferenceLogicalName; - - if (token.isCancellationRequested) { - for (let j = i; j < connectionsNeeded.length; j++) { - unfinished.push(connectionsNeeded[j].connectionReferenceLogicalName); - } - break; - } - - progress.report({ message: `${i + 1} of ${count}: ${label} — complete it in your browser...` }); - - const result = await awaitConnectionCreation({ - connectorName: connection.connectorName || connection.connectorId, - environmentId: environmentInfo.environmentId, - clusterCategory, - cancellationToken: token - }); - - if (result.status === 'cancelled') { - logger.logInfo(TelemetryEventsKeys.ConnectionCreationInfo, `Connection creation cancelled for ${connection.connectionReferenceLogicalName}.`); - unfinished.push(connection.connectionReferenceLogicalName); - continue; - } - - if (result.status === 'error') { - logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Connection creation failed for ${connection.connectionReferenceLogicalName}: ${result.errorMessage ?? 'Unknown error'}`); - unfinished.push(connection.connectionReferenceLogicalName); - continue; - } - - const connectionLogicalName = resolveConnectionLogicalName(result.connectionName, result.connectionId); - if (!connectionLogicalName) { - logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Connection created but no connection identifier was returned for ${connection.connectionReferenceLogicalName}.`); - unfinished.push(connection.connectionReferenceLogicalName); - continue; - } - - bindings.push({ - connectionReferenceLogicalName: connection.connectionReferenceLogicalName, - connectionLogicalName, - connectionDisplayName: result.displayName || connection.connectorName || undefined - }); - } - - return { bindings, unfinished }; - } - ); -}; - -const waitForCustomConnectorsAvailable = async ( - connectionsNeeded: ConnectionNeeded[], - environmentInfo: EnvironmentInfo, - clusterCategory: CoreServicesClusterCategory, - account: ConnectionRepairAccount | undefined -): Promise => { - const customConnectors = new Set(); - for (const connection of connectionsNeeded) { - const connectorName = connection.connectorName || connection.connectorId; - if (connectorName && isCustomConnectorInternalId(connectorName)) { - customConnectors.add(connectorName); - } - } - - if (customConnectors.size === 0) { - return; - } - - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Waiting for custom connectors to be ready...', - cancellable: true - }, - async (_progress, token) => { - await Promise.all( - Array.from(customConnectors, (connectorName) => - waitForConnectorAvailable({ - connectorName, - environmentId: environmentInfo.environmentId, - clusterCategory, - accountId: account?.accountId ?? null, - accountHint: account?.accountEmail, - cancellationToken: token - }) - ) - ); - } - ); -}; - -export const createAgentConnections = async ( - agentConnections: ConnectionNeeded[], - environmentInfo: EnvironmentInfo, - clusterCategory: CoreServicesClusterCategory, - account: ConnectionRepairAccount | undefined -): Promise => { - const { needed, unverified } = await resolveConnectionsNeeding(agentConnections, environmentInfo, clusterCategory, account); - - const unverifiedNames = unverified.map(c => c.connectionReferenceLogicalName); - for (const connection of unverified) { - logger.logWarning(TelemetryEventsKeys.ConnectionCreationError, `Could not verify whether the connection for ${connection.connectionReferenceLogicalName} still exists; skipping re-creation.`); - } - - if (needed.length === 0) { - return { bindings: [], unfinished: unverifiedNames }; - } - - await waitForCustomConnectorsAvailable(needed, environmentInfo, clusterCategory, account); - const result = await createConnections(needed, environmentInfo, clusterCategory); - return { bindings: result.bindings, unfinished: [...result.unfinished, ...unverifiedNames] }; -}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectorPicker.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectorPicker.ts new file mode 100644 index 0000000..4b26c59 --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectorPicker.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; +import { CopilotStudioWorkspace } from '../sync/localWorkspaces'; +import { ConnectorInfo } from '../types'; +import { listConnectors } from './connectionCatalog'; + +interface ConnectorPickItem extends vscode.QuickPickItem { + connector: ConnectorInfo; +} + +export const pickConnector = async ( + workspace: CopilotStudioWorkspace, + usedConnectorIds: ReadonlySet = new Set() +): Promise => { + const quickPick = vscode.window.createQuickPick(); + quickPick.title = 'Select a connector'; + quickPick.placeholder = 'Loading connectors…'; + quickPick.busy = true; + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; + + let hidden = false; + let resolveResult: ((value: string | undefined) => void) | undefined; + quickPick.onDidHide(() => { + hidden = true; + resolveResult?.(undefined); + }); + quickPick.show(); + + try { + let connectors: ConnectorInfo[]; + try { + connectors = await listConnectors(workspace); + } catch (error) { + quickPick.hide(); + void vscode.window.showErrorMessage((error as Error).message ?? 'Failed to list connectors.'); + return undefined; + } + + if (hidden) { + return undefined; + } + + if (connectors.length === 0) { + quickPick.hide(); + void vscode.window.showInformationMessage('No connectors were found in this environment.'); + return undefined; + } + + const sorted = [...connectors].sort((a, b) => { + const aUsed = usedConnectorIds.has(a.internalId) ? 0 : 1; + const bUsed = usedConnectorIds.has(b.internalId) ? 0 : 1; + if (aUsed !== bUsed) { + return aUsed - bUsed; + } + return a.displayName.localeCompare(b.displayName); + }); + + quickPick.items = sorted.map(connector => { + const tier = connector.tier ? ` · ${connector.tier}` : ''; + const used = usedConnectorIds.has(connector.internalId) ? ' · in use' : ''; + return { + label: connector.displayName, + description: `${connector.publisher || ''}${tier}${used}`.trim(), + detail: connector.internalId, + connector + }; + }); + quickPick.placeholder = 'Search connectors by name'; + quickPick.busy = false; + + return await new Promise(resolve => { + resolveResult = resolve; + quickPick.onDidAccept(() => { + const picked = quickPick.selectedItems[0]; + resolve(picked?.connector.internalId); + quickPick.hide(); + }); + }); + } finally { + quickPick.dispose(); + } +}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectorReadiness.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectorReadiness.ts new file mode 100644 index 0000000..caccfd6 --- /dev/null +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/connections/connectorReadiness.ts @@ -0,0 +1,78 @@ +import * as vscode from 'vscode'; +import { TelemetryEventsKeys } from '../constants'; +import logger from '../services/logger'; +import { CopilotStudioWorkspace } from '../sync/localWorkspaces'; +import { listConnectors } from './connectionCatalog'; + +const CUSTOM_CONNECTOR_INTERNAL_ID_REGEX = /-5f[0-9a-f]{16}$/i; +const DEFAULT_READINESS_TIMEOUT_MS = 30_000; +const INITIAL_POLL_DELAY_MS = 1_000; +const MAX_POLL_DELAY_MS = 3_000; + +export interface ConnectorReadinessOptions { + timeoutMs?: number; + cancellationToken?: vscode.CancellationToken; +} + +const lastSegment = (value: string): string => { + const trimmed = value.trim().replace(/\/+$/, ''); + const slash = trimmed.lastIndexOf('/'); + return slash >= 0 ? trimmed.substring(slash + 1) : trimmed; +}; + +export const isCustomConnectorInternalId = (connectorName: string): boolean => + CUSTOM_CONNECTOR_INTERNAL_ID_REGEX.test(lastSegment(connectorName)); + +const delay = (ms: number, token?: vscode.CancellationToken): Promise => + new Promise((resolve) => { + const timer = setTimeout(() => { + registration?.dispose(); + resolve(); + }, ms); + const registration = token?.onCancellationRequested(() => { + clearTimeout(timer); + registration?.dispose(); + resolve(); + }); + }); + +export const waitForCustomConnectorReady = async ( + workspace: CopilotStudioWorkspace, + connectorInternalId: string, + options?: ConnectorReadinessOptions +): Promise => { + if (!isCustomConnectorInternalId(connectorInternalId)) { + return; + } + + const target = lastSegment(connectorInternalId).toLowerCase(); + const timeoutMs = options?.timeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + let nextDelay = INITIAL_POLL_DELAY_MS; + + while (!options?.cancellationToken?.isCancellationRequested) { + try { + const connectors = await listConnectors(workspace); + if (connectors.some((connector) => lastSegment(connector.internalId).toLowerCase() === target)) { + return; + } + } catch (error) { + logger.logInfo( + TelemetryEventsKeys.ConnectionCreationInfo, + `Custom connector readiness check failed for ${target}: ${(error as Error).message}` + ); + } + + const remaining = deadline - Date.now(); + if (remaining <= 0) { + logger.logInfo( + TelemetryEventsKeys.ConnectionCreationInfo, + `Custom connector ${target} was not available within ${timeoutMs} ms; proceeding anyway.` + ); + return; + } + + await delay(Math.min(nextDelay, remaining), options?.cancellationToken); + nextDelay = Math.min(Math.floor(nextDelay * 1.5), MAX_POLL_DELAY_MS); + } +}; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/constants.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/constants.ts index 042f233..3079d66 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/constants.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/constants.ts @@ -15,11 +15,17 @@ export const LspMethods = { UPLOAD_KNOWLEDGE_FILES: "powerplatformls/uploadKnowledgeFiles", LIST_ENVIRONMENTS: "powerplatformls/listEnvironments", LIST_WORKSPACES: "workspace/listWorkspaces", - PREPARE_REATTACH: "powerplatformls/prepareReattach", REATTACH_AGENT: "powerplatformls/reattachAgent", - PREPARE_PUSH: "powerplatformls/preparePush", SYNC_PULL: "powerplatformls/syncPull", SYNC_PUSH: "powerplatformls/syncPush", + LIST_AGENT_CONNECTIONS: "powerplatformls/listAgentConnections", + APPLY_CONNECTION_BINDINGS: "powerplatformls/applyConnectionBindings", + LIST_WORKFLOW_STATUS: "powerplatformls/listWorkflowStatus", + SET_WORKFLOW_STATES: "powerplatformls/setWorkflowStates", + DECLARE_CONNECTION_REFERENCES: "powerplatformls/declareConnectionReferences", + REMOVE_CONNECTION_REFERENCE: "powerplatformls/removeConnectionReference", + LIST_CONNECTORS: "powerplatformls/listConnectors", + CREATE_CONNECTION_REFERENCE: "powerplatformls/createConnectionReference", } as const; export enum CoreServicesClusterCategory { diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/extension.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/extension.ts index 2b5574b..67298dc 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/extension.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/extension.ts @@ -19,6 +19,9 @@ import { registerOpenKnowledgeFileCommand } from './commands/openKnowledgeFile'; import { registerResetAccountCommand } from './commands/resetAccount'; import { registerSyncCommands } from './commands/syncWorkspace'; import { registerReattachAgentCommand } from './commands/reattachAgent'; +import { registerManageConnectionsCommand, registerDeclareConnectionReferenceCommand } from './commands/manageConnections'; +import { registerConnectionReferenceQuickFix } from './connections/connectionDiagnostics'; +import { registerAddConnectionReferenceCommand } from './connections/addConnectionReferenceCommand'; import { registerTelemetrySettingsListeners } from './services/telemetry'; import { maybeOpenFileFromPostOpen } from './startup/postOpen'; import { registerSignInCommand } from './commands/signIn'; @@ -73,6 +76,10 @@ export async function activate(context: vscode.ExtensionContext) { registerSessionInfoCommand(context, sessionId); registerOpenKnowledgeFileCommand(context); registerReattachAgentCommand(context); + registerManageConnectionsCommand(context); + registerDeclareConnectionReferenceCommand(context); + registerConnectionReferenceQuickFix(context); + registerAddConnectionReferenceCommand(context); initializeWorkflowFeatures(context); initializeWorkflowVisualization(context); diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/knowledgeFiles/uploadKnowledgeFiles.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/knowledgeFiles/uploadKnowledgeFiles.ts index ff3e864..feab0fb 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/knowledgeFiles/uploadKnowledgeFiles.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/knowledgeFiles/uploadKnowledgeFiles.ts @@ -5,6 +5,8 @@ import { LspMethods, TelemetryEventsKeys } from '../constants'; import logger from '../services/logger'; import { UploadKnowledgeFilesRequest, UploadKnowledgeFilesResponse } from '../types'; +const PROGRESS_NOTIFICATION_DELAY_MS = 600; + export async function uploadKnowledgeFiles(ws: CopilotStudioWorkspace): Promise { const { syncInfo, workspaceUri } = ws; if (!syncInfo || !syncInfo.dataverseEndpoint || !syncInfo.agentId) { @@ -15,20 +17,46 @@ export async function uploadKnowledgeFiles(ws: CopilotStudioWorkspace): Promise< await tryRepairAgentManagementEndpoint(syncInfo, workspaceUri); } - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: 'Uploading knowledge files…', - cancellable: true - }, async (_progress, cancellationToken) => { - const request: UploadKnowledgeFilesRequest = { - ...(await buildLspRequestPayload(syncInfo)), - workspaceUri - }; - const result = await lspClient.sendRequest(LspMethods.UPLOAD_KNOWLEDGE_FILES, request, cancellationToken); + const request: UploadKnowledgeFilesRequest = { + ...(await buildLspRequestPayload(syncInfo)), + workspaceUri + }; + + const cancellationSource = new vscode.CancellationTokenSource(); + const uploadPromise = lspClient.sendRequest(LspMethods.UPLOAD_KNOWLEDGE_FILES, request, cancellationSource.token); + const completion = uploadPromise.then(() => undefined, () => undefined); + + try { + let timer: NodeJS.Timeout | undefined; + const delay = new Promise(resolve => { + timer = setTimeout(() => resolve(true), PROGRESS_NOTIFICATION_DELAY_MS); + }); + const showProgress = await Promise.race([completion.then(() => false), delay]); + if (timer) { + clearTimeout(timer); + } + + const result = showProgress + ? await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Uploading knowledge files…', + cancellable: true + }, async (_progress, cancellationToken) => { + const subscription = cancellationToken.onCancellationRequested(() => cancellationSource.cancel()); + try { + return await uploadPromise; + } finally { + subscription.dispose(); + } + }) + : await uploadPromise; + if (result.uploaded.length) { logger.info('KnowledgeFiles', `Uploaded ${result.uploaded.length} knowledge file(s)`); logger.logInfo(TelemetryEventsKeys.UploadKnowledgeFileSuccess, `Uploaded (${result.uploaded.length}): ${result.uploaded.join(', ')}`); } - }); + } finally { + cancellationSource.dispose(); + } } diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceScm.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceScm.ts index 9acbb69..def3b0a 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceScm.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceScm.ts @@ -172,7 +172,7 @@ export async function pushNewWorkspace(context: ExtensionContext, ws: CopilotStu for (let attempt = 1; ; attempt++) { await synchronizer.pull(virtualKnowledgeProvider); try { - await synchronizer.push(true); + await synchronizer.push(true, true); break; } catch (error) { const isTransient = (error as Error).message?.includes('Improper response, not implemented'); diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceSynchronizer.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceSynchronizer.ts index 1469956..c41e7ce 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceSynchronizer.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/sync/workspaceSynchronizer.ts @@ -1,13 +1,12 @@ import * as vscode from 'vscode'; import { resetAccount } from '../clients/account'; -import { SyncRequest, SyncResponse, WorkflowResponse, AIPromptResponse, ConnectionBinding, EnvironmentInfo, PreparePushRequest, PreparePushResponse } from '../types'; +import { SyncRequest, SyncResponse, WorkflowResponse, AIPromptResponse } from '../types'; import { CopilotStudioWorkspace, tryRepairAgentManagementEndpoint } from './localWorkspaces'; import { uploadKnowledgeFiles } from '../knowledgeFiles/uploadKnowledgeFiles'; import { virtualKnowledgeFileSystemProvider } from '../knowledgeFiles/virtualKnowledgeFile'; import { knowledgeTreeDataProvider } from '../knowledgeFiles/knowledgeFileTree'; -import { DefaultCoreServicesClusterCategory, LspMethods, TelemetryEventsKeys } from '../constants'; +import { LspMethods, TelemetryEventsKeys } from '../constants'; import { lspClient, buildLspRequestPayload } from '../services/lspClient'; -import { createAgentConnections } from '../connections/connectionRepair'; import logger from '../services/logger'; let treeDataProvider: knowledgeTreeDataProvider | undefined; @@ -69,7 +68,7 @@ export async function withSyncCommandBusy(workspaceUri: string, body: () => P export interface WorkspaceSynchronizer { workspace: CopilotStudioWorkspace; syncState: SyncState; - push: (suppressErrorNotification?: boolean, connectionBindings?: ConnectionBinding[]) => Promise; + push: (suppressErrorNotification?: boolean, suppressWorkflowIssues?: boolean) => Promise; pull: (virtualProvider: virtualKnowledgeFileSystemProvider) => Promise; fetch: () => Promise; subscribe: (listener: SyncStateListener) => () => void; @@ -118,9 +117,9 @@ function getSynchronizer(ws: CopilotStudioWorkspace): WorkspaceSynchronizer { return { workspace: ws, get syncState() { return currentState; }, - push: async (suppressErrorNotification = false, connectionBindings: ConnectionBinding[] = []): Promise => { + push: async (suppressErrorNotification = false, suppressWorkflowIssues = false): Promise => { return await executeSyncOperation(async () => { - const response = await sync(ws, 'applying changes', LspMethods.SYNC_PUSH, false, suppressErrorNotification, connectionBindings); + const response = await sync(ws, 'applying changes', LspMethods.SYNC_PUSH, false, suppressErrorNotification, suppressWorkflowIssues); await uploadKnowledgeFiles(ws); return response; }, SyncState.Pushing); @@ -160,7 +159,7 @@ function getSynchronizer(ws: CopilotStudioWorkspace): WorkspaceSynchronizer { }; } -export async function sync(workspace: CopilotStudioWorkspace, displayText: string, methodName: string, silent: boolean, suppressErrorNotification = false, connectionBindings: ConnectionBinding[] = []): Promise { +export async function sync(workspace: CopilotStudioWorkspace, displayText: string, methodName: string, silent: boolean, suppressErrorNotification = false, suppressWorkflowIssues = false): Promise { const { syncInfo, workspaceUri } = workspace; if (!syncInfo) { throw new Error(`${displayText} failed. Connection file .mcs::conn.json is missing, please clone again.`); @@ -180,7 +179,6 @@ export async function sync(workspace: CopilotStudioWorkspace, displayText: strin const request: SyncRequest = { ...await buildLspRequestPayload(syncInfo), workspaceUri, - connectionBindings, }; try { @@ -190,7 +188,9 @@ export async function sync(workspace: CopilotStudioWorkspace, displayText: strin return await lspClient.sendRequest(methodName, request); }); logger.logInfo(TelemetryEventsKeys.SyncWorkspaceSuccess, `Successfully completed ${displayText}`); - logWorkflowIssues(result.workflowResponse); + if (!suppressWorkflowIssues) { + logWorkflowIssues(result.workflowResponse); + } logAIPromptIssues(result.aiPromptResponse); return result; } catch (error) { @@ -198,7 +198,7 @@ export async function sync(workspace: CopilotStudioWorkspace, displayText: strin logger.logError(TelemetryEventsKeys.SyncWorkspaceError, `Your current account does not have permission. Please sign in with the account (${accountInfo.accountEmail ?? accountInfo.accountId}) to perform this operation.`); try { resetAccount(); - return await sync(workspace, displayText, methodName, silent, suppressErrorNotification, connectionBindings); + return await sync(workspace, displayText, methodName, silent, suppressErrorNotification, suppressWorkflowIssues); } catch (error) { logger.logError(TelemetryEventsKeys.SyncWorkspaceError, `Re-authentication failed: ${(error as Error).message}`); throw error; @@ -213,63 +213,6 @@ export async function sync(workspace: CopilotStudioWorkspace, displayText: strin } } -export type PreparePushConnectionsResult = - | { status: 'ready'; bindings: ConnectionBinding[] } - | { status: 'failed' } - | { status: 'incomplete'; bindings: ConnectionBinding[]; unfinished: string[] }; - -export async function preparePushConnections(ws: CopilotStudioWorkspace): Promise { - const { syncInfo, workspaceUri } = ws; - if (!syncInfo) { - return { status: 'ready', bindings: [] }; - } - - const request: PreparePushRequest = { - ...await buildLspRequestPayload(syncInfo), - workspaceUri, - }; - - const prepareResult = await lspClient.sendRequest(LspMethods.PREPARE_PUSH, request); - if (prepareResult.code !== 200) { - logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Prepare push for connections failed: ${prepareResult.message ?? 'Unknown error'}`); - return { status: 'failed' }; - } - - if (!prepareResult.agentConnections || prepareResult.agentConnections.length === 0) { - return { status: 'ready', bindings: [] }; - } - - const environmentInfo: EnvironmentInfo = { - environmentId: syncInfo.environmentId, - dataverseUrl: syncInfo.dataverseEndpoint, - agentManagementUrl: syncInfo.agentManagementEndpoint, - displayName: '' - }; - const account = { - accountId: syncInfo.accountInfo.accountId, - accountEmail: syncInfo.accountInfo.accountEmail - }; - - let repair; - try { - repair = await createAgentConnections( - prepareResult.agentConnections, - environmentInfo, - syncInfo.accountInfo.clusterCategory ?? DefaultCoreServicesClusterCategory, - account - ); - } catch (error) { - logger.logError(TelemetryEventsKeys.ConnectionCreationError, `Error creating agent connections: ${(error as Error).message}`); - return { status: 'failed' }; - } - - if (repair.unfinished.length > 0) { - return { status: 'incomplete', bindings: repair.bindings, unfinished: repair.unfinished }; - } - - return { status: 'ready', bindings: repair.bindings }; -} - export function logWorkflowIssues(workflows: WorkflowResponse[] | undefined) { if (!workflows?.length) { return; @@ -288,9 +231,9 @@ export function logWorkflowIssues(workflows: WorkflowResponse[] | undefined) { } if (disabledWorkflows.length > 0) { - logger.logWarning(TelemetryEventsKeys.SyncWorkspaceError, `These workflows need reestablish connection and need to be enabled in MCS portal: ${disabledWorkflows.join(", ")}`); + logger.logWarning(TelemetryEventsKeys.SyncWorkspaceError, `These workflows are disabled. Bind their connections, then enable them from the connection manager: ${disabledWorkflows.join(", ")}`); } else if (failedWorkflows.length > 0) { - logger.logError(TelemetryEventsKeys.SyncWorkspaceError, `Workflow errors: ${failedWorkflows.join(", ")}`); + logger.logError(TelemetryEventsKeys.SyncWorkspaceError, `Workflow errors: ${failedWorkflows.join(", ")}`); } } diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/types.ts b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/types.ts index d681273..7d93a8d 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/types.ts +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/client/src/types.ts @@ -31,7 +31,6 @@ export interface AgentSyncInfo { export interface SyncRequest extends RemoteApiRequest { // Workspace URI: get from vscode.WorkspaceFolder; workspaceUri: string; - connectionBindings?: ConnectionBinding[]; } export interface SyncResponse extends RemoteApiResponse { @@ -163,10 +162,6 @@ export interface ClonedAssets { export interface ReattachAgentRequest extends RemoteApiRequest { workspaceUri: string; - agentSyncInfo: AgentSyncInfo; - connectionBindings?: ConnectionBinding[]; - isNewAgent: boolean; - updateWorkspaceDirectory: boolean; } export interface ReattachAgentResponse extends RemoteApiResponse { @@ -176,38 +171,164 @@ export interface ReattachAgentResponse extends RemoteApiResponse { aiPromptResponse?: AIPromptResponse[]; } -export interface PrepareReattachRequest extends RemoteApiRequest { - workspaceUri: string; +export interface ConnectionNeeded { + connectionReferenceLogicalName: string; + connectorId: string; + connectorName: string; + boundConnectionId: string; } -export interface PrepareReattachResponse extends RemoteApiResponse { - agentSyncInfo: AgentSyncInfo; - isNewAgent: boolean; - updateWorkspaceDirectory: boolean; - agentConnections?: ConnectionNeeded[]; +export interface ConnectionInstance { + name: string; + displayName: string; + status: string; + owner: string; } -export interface PreparePushRequest extends RemoteApiRequest { - workspaceUri: string; +export enum UsageKind { + Action = 0, + Topic = 1, + Workflow = 2, + Connector = 3, + ConnectionReferencesFile = 4, + BotDefinition = 5 } -export interface PreparePushResponse extends RemoteApiResponse { - agentConnections?: ConnectionNeeded[]; +export interface ConnectionReferenceUsage { + logicalName: string; + filePath: string; + kind: UsageKind; + displayName: string; } -export interface ConnectionNeeded { +export enum WorkflowState { + Unknown = 0, + Draft = 1, + Activated = 2, + Suspended = 3 +} + +export interface AgentConnectionView { connectionReferenceLogicalName: string; connectorId: string; connectorName: string; boundConnectionId: string; + boundConnectionExists: boolean; + candidates: ConnectionInstance[]; + usages: ConnectionReferenceUsage[]; + isDeclared: boolean; + catalogUnavailable?: boolean; } -export interface ConnectionBinding { +export interface WorkflowStatusView { + workflowId: string; + displayName: string; + filePath: string; + state: WorkflowState; + connectionReferenceLogicalNames: string[]; + canEnable: boolean; +} + +export interface ConnectionBindingRequest { connectionReferenceLogicalName: string; - connectionLogicalName: string; + connectionId: string; connectionDisplayName?: string; } +export interface ListAgentConnectionsRequest extends RemoteApiRequest { + workspaceUri: string; + connectionsAccessToken?: string; +} + +export interface ListAgentConnectionsResponse extends RemoteApiResponse { + agentConnections?: AgentConnectionView[]; +} + +export interface ApplyConnectionBindingsRequest extends RemoteApiRequest { + workspaceUri: string; + connectionsAccessToken?: string; + bindings: ConnectionBindingRequest[]; +} + +export interface ApplyConnectionBindingsResponse extends RemoteApiResponse { + agentConnections?: AgentConnectionView[]; +} + +export interface ListWorkflowStatusRequest extends RemoteApiRequest { + workspaceUri: string; + connectionsAccessToken?: string; +} + +export interface ListWorkflowStatusResponse extends RemoteApiResponse { + workflows?: WorkflowStatusView[]; +} + +export interface WorkflowStateChange { + workflowId: string; + activate: boolean; +} + +export interface SetWorkflowStatesRequest extends RemoteApiRequest { + workspaceUri: string; + changes: WorkflowStateChange[]; + connectionsAccessToken?: string; +} + +export interface SetWorkflowStatesResponse extends RemoteApiResponse { + succeeded: boolean; + workflows?: WorkflowStatusView[]; +} + +export interface DeclareConnectionReferencesRequest extends RemoteApiRequest { + workspaceUri: string; + logicalNames: string[]; + connectionsAccessToken?: string; +} + +export interface DeclareConnectionReferencesResponse extends RemoteApiResponse { + agentConnections?: AgentConnectionView[]; + invalidLogicalNames?: string[]; +} + +export interface RemoveConnectionReferenceRequest extends RemoteApiRequest { + workspaceUri: string; + logicalName: string; + confirmed: boolean; +} + +export interface RemoveConnectionReferenceResponse extends RemoteApiResponse { + removed: boolean; + usages?: ConnectionReferenceUsage[]; +} + +export interface ConnectorInfo { + internalId: string; + displayName: string; + publisher: string; + tier: string; + iconUri: string; +} + +export interface ListConnectorsRequest extends RemoteApiRequest { + workspaceUri: string; + connectionsAccessToken?: string; +} + +export interface ListConnectorsResponse extends RemoteApiResponse { + connectors?: ConnectorInfo[]; +} + +export interface CreateConnectionReferenceRequest extends RemoteApiRequest { + workspaceUri: string; + connectorInternalId: string; + connectionsAccessToken?: string; +} + +export interface CreateConnectionReferenceResponse extends RemoteApiResponse { + logicalName: string; + agentConnections?: AgentConnectionView[]; +} + export interface WorkflowResponse { workflowName: string; isDisabled: boolean; diff --git a/src/vscode-extensions/microsoft-powerplatformlang-extension/package.json b/src/vscode-extensions/microsoft-powerplatformlang-extension/package.json index 4718a17..11991c7 100644 --- a/src/vscode-extensions/microsoft-powerplatformlang-extension/package.json +++ b/src/vscode-extensions/microsoft-powerplatformlang-extension/package.json @@ -120,6 +120,30 @@ "command": "microsoft-copilot-studio.workflow.visualize", "when": "resourceFilename == workflow.json && resourcePath =~ /workflows/", "group": "navigation" + }, + { + "command": "microsoft-copilot-studio.manageConnections", + "when": "resourceFilename == connectionreferences.mcs.yml", + "group": "navigation" + }, + { + "command": "microsoft-copilot-studio.manageConnections", + "when": "resourcePath =~ /[\\\\/]connections[\\\\/]/ && resourceExtname == .yml", + "group": "navigation" + } + ], + "editor/context": [ + { + "command": "microsoft-copilot-studio.manageConnections", + "when": "editorLangId == CopilotStudio", + "group": "z_commands" + } + ], + "explorer/context": [ + { + "command": "microsoft-copilot-studio.manageConnections", + "when": "resourceExtname == .yml && resourceFilename =~ /\\.mcs\\.yml$/", + "group": "z_commands" } ], "view/title": [ @@ -179,6 +203,11 @@ "command": "microsoft-copilot-studio.applyChanges", "when": "view == agent-changes && viewItem == changeGroup-local", "group": "inline" + }, + { + "command": "microsoft-copilot-studio.manageConnections", + "when": "view == agent-changes && viewItem == agent", + "group": "inline" } ], "scm/resourceGroup/context": [ @@ -295,6 +324,11 @@ "title": "Copilot Studio: Reattach Agent", "icon": "$(repo-push)" }, + { + "command": "microsoft-copilot-studio.manageConnections", + "title": "Copilot Studio: Manage Connections", + "icon": "$(plug)" + }, { "command": "microsoft-copilot-studio.refreshAgentTreeView", "title": "Copilot Studio: Refresh Agents",