diff --git a/backend/src/CodeSpace.Api/Controllers/WorkflowRunsController.cs b/backend/src/CodeSpace.Api/Controllers/WorkflowRunsController.cs index 16bc18da2..56bf03d64 100644 --- a/backend/src/CodeSpace.Api/Controllers/WorkflowRunsController.cs +++ b/backend/src/CodeSpace.Api/Controllers/WorkflowRunsController.cs @@ -104,6 +104,17 @@ public async Task Get([FromRoute] string idOrNumber, Cancellation return result == null ? NotFound() : Ok(result); } + /// + /// What is wrong with the provider connection a run failed on, and what the CALLER can do about it. The run page asks this for a + /// failed turn instead of a whole run detail; 200 with null when the run recorded no failure a person can repair. Team-scoped. + /// + [HttpGet("{runId:guid}/remedy")] + public async Task GetRemedy([FromRoute] Guid runId, CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetWorkflowRunRemedyQuery { RunId = runId }, cancellationToken).ConfigureAwait(false); + return Ok(result); + } + /// /// Bounded, metadata-only canvas view. It exposes exact cell coordinates and a narrow frozen topology, never run /// outputs, node inputs/outputs, wait payloads, artifact references, prompts or node configuration. diff --git a/backend/src/CodeSpace.Core/Failures/ProviderFailureCapture.cs b/backend/src/CodeSpace.Core/Failures/ProviderFailureCapture.cs new file mode 100644 index 000000000..4ae4bf710 --- /dev/null +++ b/backend/src/CodeSpace.Core/Failures/ProviderFailureCapture.cs @@ -0,0 +1,49 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Failures; + +/// +/// Reads the failure a thrown exception DECLARED and, when it is one a person can repair outside the run, turns it into the +/// a run's record keeps. Pure and static for the reason is: it is +/// asked from the engine's node runner, from the agent executor's catch and from the bootstrap failure path, none of which should +/// need a container on a failure path. +/// +/// It asks the exception CHAIN, not only the top: a provider call that failed inside a retry, a task or an aggregate reaches +/// the engine wrapped, and the credential that was disconnected is still the reason. The first declared provider failure +/// outward-in wins; a wrapper that declares a different failure of its own is believed over what it wraps, as everywhere the +/// taxonomy is consulted. +/// +public static class ProviderFailureCapture +{ + /// + /// The provider-connection failure carries, or null for anything else — including a declared failure + /// that is not about a provider connection, which stays the sentence it was. is the repository + /// the failing step was acting on, when the step names one. + /// + public static PersistedFailure? From(Exception? thrown, Guid? repositoryId) + { + for (var failure = thrown; failure is not null; failure = failure.InnerException) + { + if (failure is AggregateException aggregate && FromAny(aggregate.InnerExceptions, repositoryId) is { } inner) return inner; + + if (failure is IFailure declared && FailureCodes.ProviderConnection.Contains(declared.Code)) return Describe(declared, repositoryId); + } + + return null; + } + + private static PersistedFailure? FromAny(IEnumerable candidates, Guid? repositoryId) => + candidates.Select(candidate => From(candidate, repositoryId)).FirstOrDefault(found => found is not null); + + /// The repository the failure names itself wins over the one the step was acting on: it is the more specific fact. + private static PersistedFailure Describe(IFailure declared, Guid? repositoryId) + { + var details = PersistedFailure.DetailsAsText(declared.Details); + var own = details is not null && details.TryGetValue(RepositoryIdDetail, out var text) && Guid.TryParse(text, out var id) ? id : (Guid?)null; + + return new PersistedFailure { Code = declared.Code, Details = details, RepositoryId = own ?? repositoryId }; + } + + /// The key a failure's details name its repository under. + public const string RepositoryIdDetail = "repositoryId"; +} diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Repositories/ListRepositoryRejectedDeliveriesQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Repositories/ListRepositoryRejectedDeliveriesQueryHandler.cs index e72a86b91..8f424ca17 100644 --- a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Repositories/ListRepositoryRejectedDeliveriesQueryHandler.cs +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Repositories/ListRepositoryRejectedDeliveriesQueryHandler.cs @@ -1,3 +1,5 @@ +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Core.Services.Identity; using CodeSpace.Core.Services.Webhooks; using CodeSpace.Messages.Dtos.Repositories; using CodeSpace.Messages.Queries.Repositories; @@ -8,9 +10,21 @@ namespace CodeSpace.Core.Handlers.QueryHandlers.Repositories; public sealed class ListRepositoryRejectedDeliveriesQueryHandler : IRequestHandler { private readonly IRejectedDeliveryReader _reader; + private readonly IProviderRemedyService _remedies; + private readonly ICurrentTeam _currentTeam; - public ListRepositoryRejectedDeliveriesQueryHandler(IRejectedDeliveryReader reader) { _reader = reader; } + public ListRepositoryRejectedDeliveriesQueryHandler(IRejectedDeliveryReader reader, IProviderRemedyService remedies, ICurrentTeam currentTeam) + { + _reader = reader; + _remedies = remedies; + _currentTeam = currentTeam; + } - public async Task Handle(ListRepositoryRejectedDeliveriesQuery request, CancellationToken cancellationToken) => - await _reader.ListForRepositoryAsync(request.RepositoryId, cancellationToken).ConfigureAwait(false); + public async Task Handle(ListRepositoryRejectedDeliveriesQuery request, CancellationToken cancellationToken) + { + var refused = await _reader.ListForRepositoryAsync(request.RepositoryId, cancellationToken).ConfigureAwait(false); + + // The refusals that blame the repository's credential carry what to do about it, for whoever is asking. + return refused with { Remedy = await _remedies.GetForRefusedDeliveriesAsync(request.RepositoryId, refused.Deliveries, _currentTeam.Id!.Value, cancellationToken).ConfigureAwait(false) }; + } } diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunByRefQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunByRefQueryHandler.cs index 5a555fd40..41b75fd45 100644 --- a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunByRefQueryHandler.cs +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunByRefQueryHandler.cs @@ -1,3 +1,4 @@ +using CodeSpace.Core.Services.Credentials.Remedy; using CodeSpace.Core.Services.Identity; using CodeSpace.Core.Services.Workflows; using CodeSpace.Core.Services.Workflows.Artifacts; @@ -16,12 +17,14 @@ public sealed class GetWorkflowRunByRefQueryHandler : IRequestHandler Handle(GetWorkflowRunByRefQuery request, CancellationToken cancellationToken) @@ -30,6 +33,11 @@ public GetWorkflowRunByRefQueryHandler(IWorkflowService service, IRunNodeOutputI var run = await _service.GetRunByRefAsync(request.IdOrNumber, teamId, cancellationToken).ConfigureAwait(false); - return run == null ? null : await _inflater.InflateAsync(run, teamId, cancellationToken).ConfigureAwait(false); + if (run == null) return null; + + var detail = await _inflater.InflateAsync(run, teamId, cancellationToken).ConfigureAwait(false); + + // What is wrong with a provider connection the run failed on, and what THIS caller can do about it — only a finished run has one. + return WorkflowRunState.IsTerminal(detail.Status) ? detail with { Remedy = await _remedies.GetForRunAsync(detail.Id, teamId, cancellationToken).ConfigureAwait(false) } : detail; } } diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunQueryHandler.cs index 191753b91..fcb21c49e 100644 --- a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunQueryHandler.cs +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunQueryHandler.cs @@ -1,3 +1,4 @@ +using CodeSpace.Core.Services.Credentials.Remedy; using CodeSpace.Core.Services.Identity; using CodeSpace.Core.Services.Workflows; using CodeSpace.Core.Services.Workflows.Artifacts; @@ -19,12 +20,14 @@ public sealed class GetWorkflowRunQueryHandler : IRequestHandler Handle(GetWorkflowRunQuery request, CancellationToken cancellationToken) @@ -33,6 +36,11 @@ public GetWorkflowRunQueryHandler(IWorkflowService service, IRunNodeOutputInflat var run = await _service.GetRunAsync(request.RunId, teamId, cancellationToken).ConfigureAwait(false); - return run == null ? null : await _inflater.InflateAsync(run, teamId, cancellationToken).ConfigureAwait(false); + if (run == null) return null; + + var detail = await _inflater.InflateAsync(run, teamId, cancellationToken).ConfigureAwait(false); + + // What is wrong with a provider connection the run failed on, and what THIS caller can do about it — only a finished run has one. + return WorkflowRunState.IsTerminal(detail.Status) ? detail with { Remedy = await _remedies.GetForRunAsync(detail.Id, teamId, cancellationToken).ConfigureAwait(false) } : detail; } } diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunRemedyQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunRemedyQueryHandler.cs new file mode 100644 index 000000000..25a20713a --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/GetWorkflowRunRemedyQueryHandler.cs @@ -0,0 +1,23 @@ +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Queries.Workflows; +using MediatR; + +namespace CodeSpace.Core.Handlers.QueryHandlers.Workflows; + +/// The lean read behind the run page's remediation card: the remedy alone, for the asking user, without the run detail around it. +public sealed class GetWorkflowRunRemedyQueryHandler : IRequestHandler +{ + private readonly IProviderRemedyService _remedies; + private readonly ICurrentTeam _currentTeam; + + public GetWorkflowRunRemedyQueryHandler(IProviderRemedyService remedies, ICurrentTeam currentTeam) + { + _remedies = remedies; + _currentTeam = currentTeam; + } + + public async Task Handle(GetWorkflowRunRemedyQuery request, CancellationToken cancellationToken) => + await _remedies.GetForRunAsync(request.RunId, _currentTeam.Id!.Value, cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs index 1d32e9ef0..5d6af2f96 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Net.Sockets; using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Failures; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents.AgentRunLogging; @@ -326,6 +327,10 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // for a re-attach is durability or a silent degrade. var brokeredHere = false; + // Hoisted for the executor-error arm, so a provider failure it lands can name the repository the run was working in: + // the failure carries the credential when it knows one, and a 401 does not. + Guid? taskRepositoryId = null; + try { // Re-check the parent workflow run's status the instant after the Queued→Running claim wins, closing the TOCTOU @@ -340,6 +345,8 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo var task = AgentTaskSecrets.Open(run, _sealer) ?? throw new InvalidOperationException($"AgentRun {agentRunId} has an empty task envelope."); + taskRepositoryId = task.RepositoryId; + // Reconcile the authored harness with the model's provider (from the pinned credential, or — for the // planner's loose model name — the pool row backing it) — if the pairing is impossible (e.g. an // Anthropic-provider model under a codex-cli default), repair to a harness that CAN drive it so the agent @@ -813,7 +820,7 @@ public async Task ExecuteAsync(Guid agentRunId, CancellationToken cancellationTo // A launch that got as far as reserving may already have billed a provider, so this terminal settles the // claim PESSIMISTICALLY (no observed cost ⇒ Indeterminate at the reserved amount) rather than leaving it // live. A failure is not evidence that nothing was spent. - await CompleteAndNotifyAsync(owner, run.TeamId, new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = ExecutorExitReason(ex), Error = redactor.Redact(ex.Message) }, cancellationToken).ConfigureAwait(false); + await CompleteAndNotifyAsync(owner, run.TeamId, new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = ExecutorExitReason(ex), Error = redactor.Redact(ex.Message), Failure = ProviderFailureCapture.From(ex, taskRepositoryId) }, cancellationToken).ConfigureAwait(false); } finally { @@ -2262,7 +2269,7 @@ internal async Task PushProducedBranchIfEnabledAsync(AgentRunOwn // token already redacted (the handle redacts it), so it's safe to persist onto the timeline. _logger.LogWarning(ex, "Agent run {RunId}: failed to push the produced branch ({Reason}); the run stays Succeeded with no branch output", runId, DescribePushFailure(ex)); await AppendPushFailureWarningAsync(owner, ex.Message, cancellationToken).ConfigureAwait(false); - return result with { PublishError = ex.Message, PublishFailureCode = PublishFailureCodeOf(ex) }; + return result with { PublishError = ex.Message, PublishFailureCode = PublishFailureCodeOf(ex), Failure = ProviderFailureCapture.From(ex, task.RepositoryId) }; } } diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/GitCredentialRejectedException.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/GitCredentialRejectedException.cs index 1b730d41d..0d55663f8 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/GitCredentialRejectedException.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/GitCredentialRejectedException.cs @@ -21,7 +21,24 @@ public sealed class GitCredentialRejectedException : Exception, IFailure public string Code => FailureCodes.GitCredentialRejected; + /// What a person acting on the failure needs to find the subject: the repository the push was for and, when the refusal came from a credential that is gone, that credential — both identifiers, never a token. + public IReadOnlyDictionary? Details => _details; + + private readonly Dictionary? _details; + public GitCredentialRejectedException(string message) : base(message) { } - public GitCredentialRejectedException(string message, Exception inner) : base(message, inner) { } + public GitCredentialRejectedException(string message, Guid? repositoryId) : base(message) { _details = DetailsOf(repositoryId, inner: null); } + + public GitCredentialRejectedException(string message, Exception inner, Guid? repositoryId = null) : base(message, inner) { _details = DetailsOf(repositoryId, inner); } + + private static Dictionary? DetailsOf(Guid? repositoryId, Exception? inner) + { + var details = new Dictionary(); + + if (repositoryId is { } repository) details["repositoryId"] = repository; + if (inner is IFailure { Details: { } innerDetails } && innerDetails.TryGetValue("credentialId", out var credentialId)) details["credentialId"] = credentialId; + + return details.Count == 0 ? null : details; + } } diff --git a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs index 5ea8bc783..d2367c269 100644 --- a/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs +++ b/backend/src/CodeSpace.Core/Services/Agents/Workspace/Providers/LocalGitWorkspaceProvider.cs @@ -973,11 +973,11 @@ private async Task UseCurrentCredentialAsync(MaterializedRepo repo, Cancellation } catch (CredentialDisconnectedException ex) { - throw new GitCredentialRejectedException($"The credential this repository pushes with was disconnected while the run worked, so its branch could not be published. {ReconnectRemedy} ({Redact(ex.Message, repo.Token)})", ex); + throw new GitCredentialRejectedException($"The credential this repository pushes with was disconnected while the run worked, so its branch could not be published. {ReconnectRemedy} ({Redact(ex.Message, repo.Token)})", ex, repo.RepositoryId); } catch (CredentialNeedsReconnectException ex) { - throw new GitCredentialRejectedException($"The credential this repository pushes with was refused by its provider while the run worked, so its branch could not be published. {ReconnectRemedy} ({Redact(ex.Message, repo.Token)})", ex); + throw new GitCredentialRejectedException($"The credential this repository pushes with was refused by its provider while the run worked, so its branch could not be published. {ReconnectRemedy} ({Redact(ex.Message, repo.Token)})", ex, repo.RepositoryId); } catch (Exception ex) when (!cancellationToken.IsCancellationRequested) { @@ -1036,7 +1036,7 @@ private async Task PushToRemoteOrThrowAsync(MaterializedRepo repo, string publis } private static GitCredentialRejectedException Rejected(MaterializedRepo repo, string remoteUrl, string detail, SandboxResult refusal) => - new($"The remote refused the git credential for {remoteUrl}, {detail}, so the branch was not published. {ReconnectRemedy} git said: {Redact(Summarize(refusal.Stderr), repo.Token)}"); + new($"The remote refused the git credential for {remoteUrl}, {detail}, so the branch was not published. {ReconnectRemedy} git said: {Redact(Summarize(refusal.Stderr), repo.Token)}", repo.RepositoryId); /// Commit everything staged under a fixed CodeSpace identity; returns false (no commit) when there was nothing to commit. The identity AND commit.gpgsign=false are set inline via -c so the clone's git config is never mutated — and the automated capture commit can never inherit a host/global commit.gpgsign=true that would make it block on a signing key the unattended agent does not have (which would fail the branch push → the produced branch is silently lost). An internal automation commit under a synthetic identity has no meaningful signature, so signing is always disabled here. private async Task CommitOrDetectEmptyAsync(MaterializedRepo repo, string branchName, CancellationToken cancellationToken) diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs index 02c4edfdf..bf3abed1a 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs @@ -99,15 +99,25 @@ private async Task> WithHealthAsync(List s.Id).ToList(); var left = await DepartedCredentials.Of(_db).AsNoTracking().Where(c => c.TeamId == teamId && ids.Contains(c.Id)).Select(c => c.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + var bound = await CountBoundRepositoriesAsync(teamId, ids, cancellationToken).ConfigureAwait(false); var now = DateTimeOffset.UtcNow; return summaries.Select(s => s with { Health = CredentialHealthDerivation.Derive(s.Status, s.AuthType, s.ExpiresDate, left.Contains(s.Id), now), - DaysToExpiry = CredentialHealthDerivation.DaysToExpiry(s.AuthType, s.ExpiresDate, now) + DaysToExpiry = CredentialHealthDerivation.DaysToExpiry(s.AuthType, s.ExpiresDate, now), + BoundRepositoryCount = bound.GetValueOrDefault(s.Id) }).ToList(); } + /// How many live repositories each listed credential holds — one grouped read for the whole list, so the number beside a credential (what reconnecting or disconnecting it puts at stake) costs the list no more than one query. + private async Task> CountBoundRepositoriesAsync(Guid teamId, List credentialIds, CancellationToken cancellationToken) => + await _db.Repository.AsNoTracking() + .Where(r => r.TeamId == teamId && r.DeletedDate == null && r.CredentialId != null && credentialIds.Contains(r.CredentialId.Value)) + .GroupBy(r => r.CredentialId!.Value) + .Select(g => new { CredentialId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.CredentialId, g => g.Count, cancellationToken).ConfigureAwait(false); + public async Task GetUsageAsync(Guid credentialId, CancellationToken cancellationToken) { var credential = await LoadCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false); diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/CallerStanding.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/CallerStanding.cs new file mode 100644 index 000000000..db2ec5ec6 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/CallerStanding.cs @@ -0,0 +1,29 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +/// +/// What the person asking may do about a provider failure, decided by the SAME rules the endpoints that do it apply — so a button +/// is enabled exactly when the request behind it would be accepted. Pure: the team role and whether the credential is the +/// caller's own are the only inputs. +/// +/// +/// repos.manage (Admin and Owner): re-link a repository, edit a provider's address. +/// credentials.manage (Admin and Owner): reconnect a sign-in. POST /api/credentials/oauth/init is a +/// credentials.manage request whoever the credential belongs to — owning the row does not pass it. +/// The credential's own person, or credentials.manage: replace a pasted token — the rule +/// applies to PUT /api/credentials/{id}/token. +/// runs.launch (Member and up): run it again. +/// +/// +public sealed record CallerStanding(bool MayManageRepositories, bool MayReconnectTheCredential, bool MayReplaceTheToken, bool MayLaunchRuns) +{ + /// : the credential is Personal and the caller is its person — which passes the credential rule on membership alone. + public static CallerStanding Of(TeamRole role, bool ownsTheCredential) => new( + MayManageRepositories: TeamPermissionMatrix.IsGranted(role, TeamPermissions.ReposManage), + MayReconnectTheCredential: TeamPermissionMatrix.IsGranted(role, TeamPermissions.CredentialsManage), + MayReplaceTheToken: ownsTheCredential || TeamPermissionMatrix.IsGranted(role, TeamPermissions.CredentialsManage), + MayLaunchRuns: TeamPermissionMatrix.IsGranted(role, TeamPermissions.RunsLaunch)); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/IProviderRemedyService.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/IProviderRemedyService.cs new file mode 100644 index 000000000..885e2cd94 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/IProviderRemedyService.cs @@ -0,0 +1,24 @@ +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Dtos.Repositories; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +/// +/// Answers "what is wrong with this provider connection, and what can I do about it" for the person asking: from the failure a +/// run recorded, or from the trigger refusals that blame a repository's credential. The answer is read fresh each time — it +/// names what is true now (a credential reconnected since reads as resolved) and what THIS caller may do (an Owner, a Member and +/// a Viewer get different buttons for the same failure) — which is why it is never cached with the page that shows it. +/// +public interface IProviderRemedyService +{ + /// The remedy for the provider failure a run recorded, or null when it recorded none (or is not the team's). Team-scoped. + Task GetForRunAsync(Guid runId, Guid teamId, CancellationToken cancellationToken); + + /// + /// The remedy for the deliveries a repository's trigger refused because of its credential — credential_rejected and + /// author_standing_unknown — or null when none of was. One remedy serves them all: it is about the + /// credential the repository runs through NOW, so it is built from the newest such refusal, and a credential validated since that + /// refusal reads as fixed. + /// + Task GetForRefusedDeliveriesAsync(Guid repositoryId, IReadOnlyList deliveries, Guid teamId, CancellationToken cancellationToken); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Assemble.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Assemble.cs new file mode 100644 index 000000000..61e50d7e1 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Assemble.cs @@ -0,0 +1,137 @@ +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Core.Services.Redaction; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +public sealed partial class ProviderRemedyService +{ + /// What works now: a redirect is resolved by the provider's address having moved off the redirecting origin; anything else by every repository the failure names authenticating through a credential that works (or, naming none, the credential itself). + private void DecideResolved() + { + var now = DateTimeOffset.UtcNow; + + _ctx.Resolved = _ctx.Code == Messages.Failures.FailureCodes.ProviderBaseUrlRedirects + ? _ctx.Instance is { } instance && RemedyResolution.RedirectIsResolved(_ctx.Failure.Detail("requestedOrigin"), instance.BaseUrl) + : RepositoriesWork(now); + } + + private bool RepositoriesWork(DateTimeOffset now) + { + if (_ctx.Repositories.Count > 0) return _ctx.Repositories.All(repository => RepositoryWorks(repository, now)); + + return _ctx.Credential is { } credential && RemedyResolution.FailingCredentialIsFixed(StateOf(credential), _ctx.Code, _ctx.At, now); + } + + private bool RepositoryWorks(RepositoryRow repository, DateTimeOffset now) + { + var current = repository.CredentialId is { } id && _ctx.Credentials.TryGetValue(id, out var row) ? StateOf(row) : null; + + return RemedyResolution.RepositoryWorks(current, _ctx.Failure.DetailGuid("credentialId"), _ctx.Code, _ctx.At, now); + } + + private static RemedyResolution.CredentialState StateOf(CredentialRow c) => new(c.Id, c.Status, c.AuthType, c.ExpiresDate, c.LastValidatedDate, Retired: c.DeletedDate != null); + + private void StateFacts() + { + var credential = _ctx.Credential; + + _ctx.Facts = new RemedyFacts + { + Provider = _ctx.Instance?.Provider.ToString(), + CredentialName = credential?.DisplayName ?? _ctx.Failure.Detail("credentialName"), + OwnerName = _ctx.OwnerName, + CallerOwnsCredential = OwnsCredential(credential), + CredentialRetired = credential?.DeletedDate != null, + CredentialIsStaticToken = credential is { } c && CredentialHealthDerivation.IsStaticToken(c.AuthType), + CredentialIsTeamService = credential?.Ownership == CredentialOwnership.TeamService, + Repositories = RepositoryPhrase(), + RepositoryCount = _ctx.Repositories.Count, + RedirectsTo = _ctx.Failure.Detail("finalOrigin"), + RequestedOrigin = _ctx.Failure.Detail("requestedOrigin"), + RedirectFix = RedirectFixFor(), + ConnectedCredentials = _ctx.ConnectedCredentials, + InstanceName = _ctx.Instance?.DisplayName, + ProviderStatus = _ctx.Failure.Detail("providerStatus"), + OAuthError = _ctx.Failure.Detail("oauthError"), + Lookup = _ctx.Failure.Detail(Messages.Constants.StandingLookupCauses.DetailKey), + Resolved = _ctx.Resolved, + FixIsObservable = RemedyResolution.FixIsObservable(_ctx.Code) || _ctx.Code == Messages.Constants.WorkflowRunRequestRejectionReasons.CredentialRejected + }; + } + + /// For a redirect on a known provider connection, the repair its server would accept; nothing otherwise. + private RedirectRepair? RedirectFixFor() => + _ctx.Code == Messages.Failures.FailureCodes.ProviderBaseUrlRedirects && _ctx.Instance is { } instance + ? RemedyResolution.RepairRedirect(_ctx.Failure.Detail("requestedOrigin"), _ctx.Failure.Detail("finalOrigin"), instance.BaseUrl, instance.ApiUrl, _ctx.ConnectedCredentials) + : null; + + private bool OwnsCredential(CredentialRow? credential) => credential is { Ownership: CredentialOwnership.Personal, OwnerUserId: { } owner } && owner == _ctx.CallerId; + + private string? RepositoryPhrase() => _ctx.Repositories.Count switch + { + 0 => null, + 1 => _ctx.Repositories[0].FullPath, + 2 => $"{_ctx.Repositories[0].FullPath} and {_ctx.Repositories[1].FullPath}", + var many => $"{many} repositories" + }; + + private void ChooseActions() + { + var caller = CallerStanding.Of(_ctx.Role, OwnsCredential(_ctx.Credential)); + var subjects = new RemedyActions.ActionSubjects(HasCredential: _ctx.Credential is not null, HasRepositories: _ctx.Repositories.Count > 0, HasProviderInstance: _ctx.Instance is not null, OffersRerun: _ctx.OffersRerun); + + _ctx.Actions = RemedyActions.Build(_ctx.Code, _ctx.Facts, caller, subjects); + } + + private async Task AssembleAsync(CancellationToken cancellationToken) => new() + { + Code = _ctx.Code, + Title = RemedyCopy.Title(_ctx.Code, _ctx.Facts), + Cause = RemedyCopy.Cause(_ctx.Code, _ctx.Facts), + Resolved = _ctx.Resolved, + Credential = _ctx.Credential is { } credential ? ToCredential(credential) : null, + Repositories = _ctx.Repositories.Select(repository => ToRepository(repository)).ToList(), + ProviderInstance = _ctx.Instance is { } instance ? new RemedyProviderInstance { Id = instance.Id, DisplayName = instance.DisplayName, Provider = instance.Provider, BaseUrl = CredentialUrl.Masked(instance.BaseUrl)! } : null, + RedirectsTo = _ctx.Failure.Detail("finalOrigin"), + Actions = _ctx.Actions, + AskPeople = await WhoToAskAsync(cancellationToken).ConfigureAwait(false) + }; + + private RemedyCredential ToCredential(CredentialRow c) => new() + { + Id = c.Id, + DisplayName = c.DisplayName, + Ownership = c.Ownership, + OwnerUserId = c.OwnerUserId, + OwnerName = _ctx.OwnerName, + AuthType = c.AuthType, + Status = c.Status, + Health = CredentialHealthDerivation.Derive(c.Status, c.AuthType, c.ExpiresDate, ownerLeft: false, DateTimeOffset.UtcNow), + Retired = c.DeletedDate != null, + ProviderInstanceId = c.ProviderInstanceId + }; + + private RemedyRepository ToRepository(RepositoryRow r) => new() { Id = r.Id, FullPath = r.FullPath, Status = r.Status, CredentialId = r.CredentialId, CredentialWorks = RepositoryWorks(r, DateTimeOffset.UtcNow) }; + + /// Who to ask when something is not the caller's to do: the credential's own person for a repair only they can make, then the team's Owners and Admins. Empty when every action is the caller's — a list of people to ask beside buttons that work would only be noise. + private async Task> WhoToAskAsync(CancellationToken cancellationToken) + { + if (!_ctx.Actions.Any(a => !a.Enabled && a.Kind is not RemedyActionKind.Rerun)) return Array.Empty(); + + var people = new List(); + + // Only a token replace can be the credential's own person's to make; a reconnect needs credentials.manage whoever owns it, so naming them would send the asker to someone who cannot do it either. + var repairIsTheirs = _ctx.Actions.Any(a => !a.Enabled && a.Kind is RemedyActionKind.ReplaceToken); + + if (repairIsTheirs && _ctx.Credential is { Ownership: CredentialOwnership.Personal, OwnerUserId: { } owner } && owner != _ctx.CallerId && _ctx.OwnerName is { } ownerName) + people.Add(new RemedyPerson { UserId = owner, Name = ownerName, Role = "Owner of the credential" }); + + var administrators = await ReadTeamAdministratorsAsync(cancellationToken).ConfigureAwait(false); + + people.AddRange(administrators.Where(a => a.UserId != _ctx.CallerId && people.All(p => p.UserId != a.UserId)).Select(a => new RemedyPerson { UserId = a.UserId, Name = a.Name, Role = a.Role.ToString() })); + + return people.Take(MaxPeople).ToList(); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Context.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Context.cs new file mode 100644 index 000000000..f4aea6338 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Context.cs @@ -0,0 +1,51 @@ +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +public sealed partial class ProviderRemedyService +{ + /// One remedy's working set: what was asked, what was read, what was decided. A new one per call, so a scoped service answering twice never lets the first answer leak into the second. + private sealed class RemedyContext + { + public required Guid TeamId { get; init; } + + /// The failure's code for a run; the refusal's reason for a delivery. + public required string Code { get; init; } + + public required PersistedFailure Failure { get; init; } + public required DateTimeOffset At { get; init; } + public required IReadOnlyList ScopeRepositoryIds { get; init; } + + /// False for a refused delivery: there is no run to run again. + public required bool OffersRerun { get; init; } + + public List Repositories { get; set; } = new(); + public Dictionary Credentials { get; set; } = new(); + + /// The credential the remedy is about: the failure's own, else the one the first repository runs through. + public CredentialRow? Credential { get; set; } + + public string? OwnerName { get; set; } + public InstanceRow? Instance { get; set; } + + /// Credentials still presented through the instance — read only for a redirect, where it decides whether editing the address is accepted. + public int ConnectedCredentials { get; set; } + + public TeamRole Role { get; set; } + public Guid? CallerId { get; set; } + + public bool Resolved { get; set; } + public RemedyFacts Facts { get; set; } = default!; + public IReadOnlyList Actions { get; set; } = Array.Empty(); + } + + private sealed record RepositoryRow(Guid Id, string FullPath, RepositoryStatus Status, Guid? CredentialId, Guid ProviderInstanceId); + + /// A credential narrowed to what a remedy reads — deliberately NOT the entity, which carries the sealed token. + private sealed record CredentialRow(Guid Id, string DisplayName, CredentialOwnership Ownership, Guid? OwnerUserId, AuthType AuthType, CredentialStatus Status, DateTimeOffset? ExpiresDate, DateTimeOffset? LastValidatedDate, DateTimeOffset? DeletedDate, Guid ProviderInstanceId); + + /// A provider connection narrowed the same way: its address, never its OAuth secret. + private sealed record InstanceRow(Guid Id, string DisplayName, ProviderKind Provider, string BaseUrl, string? ApiUrl); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Load.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Load.cs new file mode 100644 index 000000000..5798461ee --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.Load.cs @@ -0,0 +1,111 @@ +using System.Linq.Expressions; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +public sealed partial class ProviderRemedyService +{ + /// The most repositories one remedy names. A run launched against dozens still gets a card a person can read; the count it states is of what it names. + private const int MaxRepositories = 10; + + /// The repositories the failure was about: the one it names, else the ones the run was launched against, else — when it names a credential and nothing else — whatever still leans on that credential. + private async Task LoadRepositoriesAsync(CancellationToken cancellationToken) + { + var named = RepositoryIdsInPlay(); + var credentialId = _ctx.Failure.DetailGuid("credentialId"); + + if (named.Count > 0) _ctx.Repositories = await ReadRepositoriesAsync(r => named.Contains(r.Id), cancellationToken).ConfigureAwait(false); + else if (credentialId is { } leaning) _ctx.Repositories = await ReadRepositoriesAsync(r => r.CredentialId == leaning, cancellationToken).ConfigureAwait(false); + } + + private IReadOnlyList RepositoryIdsInPlay() => _ctx.Failure.RepositoryId is { } own ? new[] { own } : _ctx.ScopeRepositoryIds; + + private async Task> ReadRepositoriesAsync(Expression> predicate, CancellationToken cancellationToken) => + await _db.Repository.AsNoTracking() + .Where(r => r.TeamId == _ctx.TeamId && r.DeletedDate == null) + .Where(predicate) + .OrderBy(r => r.FullPath) + .Take(MaxRepositories) + .Select(r => new RepositoryRow(r.Id, r.FullPath, r.Status, r.CredentialId, r.ProviderInstanceId)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + /// Every credential in play, retired or not: a retired one is exactly the one a remedy has to be able to name. + private async Task LoadCredentialsAsync(CancellationToken cancellationToken) + { + var ids = _ctx.Repositories.Select(r => r.CredentialId).Append(_ctx.Failure.DetailGuid("credentialId")).OfType().Distinct().ToList(); + + if (ids.Count == 0) return; + + var rows = await _db.Credential.AsNoTracking() + .Where(c => c.TeamId == _ctx.TeamId && ids.Contains(c.Id)) + .Select(c => new CredentialRow(c.Id, c.DisplayName, c.Ownership, c.OwnerUserId, c.AuthType, c.Status, c.ExpiresDate, c.LastValidatedDate, c.DeletedDate, c.ProviderInstanceId)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + _ctx.Credentials = rows.ToDictionary(c => c.Id); + } + + private void ChooseCredential() + { + var failing = _ctx.Failure.DetailGuid("credentialId"); + + _ctx.Credential = failing is { } id && _ctx.Credentials.TryGetValue(id, out var named) + ? named + : _ctx.Repositories.Select(r => r.CredentialId).OfType().Select(bound => _ctx.Credentials.GetValueOrDefault(bound)).FirstOrDefault(c => c is not null); + } + + private async Task LoadOwnerNameAsync(CancellationToken cancellationToken) + { + if (_ctx.Credential?.OwnerUserId is not { } owner) return; + + _ctx.OwnerName = await _db.User.IgnoreQueryFilters().AsNoTracking().Where(u => u.Id == owner).Select(u => u.Name).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + } + + /// The credential's provider connection, else the repository's, else — for a redirect that named only the address it met — the team's connection that answers on it. + private async Task LoadProviderInstanceAsync(CancellationToken cancellationToken) + { + var id = _ctx.Credential?.ProviderInstanceId ?? _ctx.Repositories.Select(r => (Guid?)r.ProviderInstanceId).FirstOrDefault(); + + var rows = await _db.ProviderInstance.AsNoTracking() + .Where(i => i.TeamId == _ctx.TeamId && i.DeletedDate == null && (id == null || i.Id == id)) + .Select(i => new InstanceRow(i.Id, i.DisplayName, i.Provider, i.BaseUrl, i.ApiUrl)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + _ctx.Instance = id is not null ? rows.FirstOrDefault() : rows.FirstOrDefault(i => AnswersOn(i, _ctx.Failure.Detail("requestedOrigin"))); + } + + private static bool AnswersOn(InstanceRow instance, string? origin) => + origin is { Length: > 0 } && Uri.TryCreate(instance.BaseUrl, UriKind.Absolute, out var url) && string.Equals($"{url.Scheme}://{url.IdnHost}{(url.IsDefaultPort ? "" : ":" + url.Port)}", origin, StringComparison.OrdinalIgnoreCase); + + /// The credentials the provider would still present — connected through it, not retired and not disconnected: ProviderInstanceService refuses to move the host of an instance while any exist, so a redirect's repair depends on the count. + private async Task LoadConnectedCredentialsAsync(CancellationToken cancellationToken) + { + if (_ctx.Code != FailureCodes.ProviderBaseUrlRedirects || _ctx.Instance is not { } instance) return; + + _ctx.ConnectedCredentials = await _db.Credential.AsNoTracking() + .CountAsync(c => c.TeamId == _ctx.TeamId && c.ProviderInstanceId == instance.Id && c.DeletedDate == null && c.Status != CredentialStatus.Revoked, cancellationToken).ConfigureAwait(false); + } + + private async Task ResolveCallerAsync(CancellationToken cancellationToken) + { + _ctx.Role = await _membership.ResolveRoleAsync(_ctx.TeamId, cancellationToken).ConfigureAwait(false); + _ctx.CallerId = _currentUser.Id; + } + + private async Task> ReadTeamAdministratorsAsync(CancellationToken cancellationToken) + { + var rows = await _db.TeamMembership.AsNoTracking() + .Where(m => m.TeamId == _ctx.TeamId && (m.Role == TeamRole.Owner || m.Role == TeamRole.Admin)) + .Select(m => new { m.UserId, m.User.Name, m.Role }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + // Owners before Admins, then by name — ordered here because a role is stored as its name, which sorts the wrong way round. + return rows.OrderByDescending(m => TeamRoleRank.Of(m.Role)).ThenBy(m => m.Name, StringComparer.OrdinalIgnoreCase).Select(m => (m.UserId, m.Name, m.Role)).ToList(); + } + + /// How many people a remedy names to ask. A team of forty Admins is not a list a person can use. + private const int MaxPeople = 5; +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.cs new file mode 100644 index 000000000..07025d5b3 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/ProviderRemedyService.cs @@ -0,0 +1,94 @@ +using CodeSpace.Core.Authorization; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.Workflows.Failures; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Dtos.Repositories; +using CodeSpace.Messages.Failures; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +/// +/// Builds a : reads the failure, reads what it names as it is now, reads who is asking, and states what +/// is wrong and what they can do — one flat pipeline, each step its own method, with the words and the permission rules kept in +/// the pure , and so they can be tested without a database. +/// +/// Read-only. Nothing here changes a credential, a repository or a run: every action the remedy offers is a request the +/// client makes of the endpoint that already does it, under that endpoint's own authorization. +/// +public sealed partial class ProviderRemedyService : IProviderRemedyService, IScopedDependency +{ + private readonly CodeSpaceDbContext _db; + private readonly ICurrentUser _currentUser; + private readonly TeamMembershipResolver _membership; + private readonly IRunFailureReader _failures; + + private RemedyContext _ctx = default!; + + public ProviderRemedyService(CodeSpaceDbContext db, ICurrentUser currentUser, TeamMembershipResolver membership, IRunFailureReader failures) + { + _db = db; + _currentUser = currentUser; + _membership = membership; + _failures = failures; + } + + public async Task GetForRunAsync(Guid runId, Guid teamId, CancellationToken cancellationToken) + { + var recorded = await _failures.ReadAsync(runId, teamId, cancellationToken).ConfigureAwait(false); + + if (recorded is null) return null; + + _ctx = new RemedyContext { TeamId = teamId, Code = recorded.Failure.Code, Failure = recorded.Failure, At = recorded.At, ScopeRepositoryIds = recorded.ScopeRepositoryIds, OffersRerun = true }; + + return await BuildAsync(cancellationToken).ConfigureAwait(false); + } + + public async Task GetForRefusedDeliveriesAsync(Guid repositoryId, IReadOnlyList deliveries, Guid teamId, CancellationToken cancellationToken) + { + var newest = NewestRefusalBlamingTheCredential(deliveries, repositoryId); + + if (newest is null) return null; + + var failure = new PersistedFailure { Code = newest.Reason, RepositoryId = repositoryId, Details = await ReadLookupAsync(newest.Id, teamId, cancellationToken).ConfigureAwait(false) }; + + _ctx = new RemedyContext { TeamId = teamId, Code = newest.Reason, Failure = failure, At = newest.ReceivedAt, ScopeRepositoryIds = Array.Empty(), OffersRerun = false }; + + return await BuildAsync(cancellationToken).ConfigureAwait(false); + } + + /// What the refusal row kept of why the lookup failed (, the status the provider answered). Null for a refusal blamed on the credential outright, and for a row written before it was kept. + private async Task?> ReadLookupAsync(Guid requestId, Guid teamId, CancellationToken cancellationToken) + { + var metadata = await _db.WorkflowRunRequest.AsNoTracking().Where(r => r.Id == requestId && r.TeamId == teamId).Select(r => r.RequestMetadataJson).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + return PersistedFailureJson.Read(metadata)?.Details; + } + + /// The newest refusal that blames the repository's credential. A delivery that could not be placed on any repository says nothing about this one's. + private static RejectedDelivery? NewestRefusalBlamingTheCredential(IReadOnlyList deliveries, Guid repositoryId) => + deliveries + .Where(d => d.RepositoryId == repositoryId && d.Reason is WorkflowRunRequestRejectionReasons.CredentialRejected or WorkflowRunRequestRejectionReasons.AuthorStandingUnknown) + .OrderByDescending(d => d.ReceivedAt) + .FirstOrDefault(); + + private async Task BuildAsync(CancellationToken cancellationToken) + { + await LoadRepositoriesAsync(cancellationToken).ConfigureAwait(false); + await LoadCredentialsAsync(cancellationToken).ConfigureAwait(false); + ChooseCredential(); + await LoadOwnerNameAsync(cancellationToken).ConfigureAwait(false); + await LoadProviderInstanceAsync(cancellationToken).ConfigureAwait(false); + await LoadConnectedCredentialsAsync(cancellationToken).ConfigureAwait(false); + await ResolveCallerAsync(cancellationToken).ConfigureAwait(false); + + DecideResolved(); + StateFacts(); + ChooseActions(); + + return await AssembleAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyActions.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyActions.cs new file mode 100644 index 000000000..252c98943 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyActions.cs @@ -0,0 +1,104 @@ +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +/// +/// The fixes a remedy offers, in the order a person should try them, each saying whether THIS caller may do it and, when not, +/// what they would need. Pure over the code, the facts and : the same inputs always give the same +/// list, which is what lets the per-role matrix be a unit test and keeps the button state from ever being a client's opinion. +/// +/// Ordering is the product's judgement, in one place: a credential that still exists is best put right IN PLACE +/// (reconnect, replace the token — nothing bound through it moves); one that was retired can only be moved off, so re-linking +/// leads. Once what failed works again, running it again leads. A disabled action is kept in the list with its reason rather than +/// dropped — a person who cannot fix it must still learn that it is fixable, and by whom. +/// +public static class RemedyActions +{ + public static IReadOnlyList Build(string code, RemedyFacts facts, CallerStanding caller, ActionSubjects subjects) + { + var actions = new List(); + var rerun = subjects.OffersRerun ? Rerun(facts, caller) : null; + + if (facts.Resolved && rerun is not null) actions.Add(rerun); + + actions.AddRange(RepairsFor(code, facts, caller, subjects)); + actions.Add(OpenProviders()); + + if (!facts.Resolved && rerun is not null) actions.Add(rerun); + + return actions; + } + + /// What is wrong with a redirect is the provider's address — edited only when the server will accept the edit (), else the cause says what works; what is wrong with anything else is a credential — repaired in place, or moved off. + private static IEnumerable RepairsFor(string code, RemedyFacts facts, CallerStanding caller, ActionSubjects subjects) + { + if (code == FailureCodes.ProviderBaseUrlRedirects) return subjects.HasProviderInstance && facts.RedirectFix == RedirectRepair.EditAddress ? new[] { EditProviderAddress(caller) } : Array.Empty(); + + if (SaysNothingAboutTheCredential(code, facts)) return Array.Empty(); + + var repair = subjects.HasCredential && !facts.CredentialRetired ? RepairCredential(facts, caller) : null; + var relink = subjects.HasRepositories ? Relink(facts, caller) : null; + + return (facts.CredentialRetired || repair is null ? new[] { relink, repair } : new[] { repair, relink }).OfType(); + } + + /// A renewal the provider did not answer (a refusal of the grant is credential_needs_reconnect) and a standing lookup that got no answer say nothing against the credential, so nobody is sent to reconnect, replace or move off it for them. + private static bool SaysNothingAboutTheCredential(string code, RemedyFacts facts) => + code == FailureCodes.OAuthExchangeFailed || (code == WorkflowRunRequestRejectionReasons.AuthorStandingUnknown && facts.Lookup == StandingLookupCauses.NoAnswer); + + /// Reconnecting a sign-in and replacing a pasted token are two endpoints with two rules (), so each is enabled by its own. + private static RemedyAction RepairCredential(RemedyFacts facts, CallerStanding caller) => facts.CredentialIsStaticToken ? ReplaceToken(facts, caller) : Reconnect(facts, caller); + + private static RemedyAction ReplaceToken(RemedyFacts facts, CallerStanding caller) => new() + { + Kind = RemedyActionKind.ReplaceToken, + Label = "Replace token", + Enabled = caller.MayReplaceTheToken, + DisabledReason = caller.MayReplaceTheToken ? null : facts.CredentialIsTeamService ? NeedsAdmin : $"Only {facts.OwnerName ?? "the person who connected it"}, or a team Admin, can do this." + }; + + private static RemedyAction Reconnect(RemedyFacts facts, CallerStanding caller) => new() + { + Kind = RemedyActionKind.Reconnect, + Label = facts.Provider is { Length: > 0 } provider ? $"Reconnect {provider}" : "Reconnect", + Enabled = caller.MayReconnectTheCredential, + DisabledReason = caller.MayReconnectTheCredential ? null : NeedsAdmin + }; + + private static RemedyAction Relink(RemedyFacts facts, CallerStanding caller) => new() + { + Kind = RemedyActionKind.Relink, + Label = facts.RepositoryCount > 1 ? "Re-link repositories" : "Re-link repository", + Enabled = caller.MayManageRepositories, + DisabledReason = caller.MayManageRepositories ? null : NeedsAdmin + }; + + private static RemedyAction EditProviderAddress(CallerStanding caller) => new() + { + Kind = RemedyActionKind.EditProviderUrl, + Label = "Edit provider address", + Enabled = caller.MayManageRepositories, + DisabledReason = caller.MayManageRepositories ? null : NeedsAdmin + }; + + private static RemedyAction OpenProviders() => new() { Kind = RemedyActionKind.OpenProviders, Label = "Open Providers", Enabled = true }; + + /// Run it again. Held back until the fix shows only where a fix can be SEEN (); a provider's 401 on a row that looks fine leaves it open, because a person may have fixed it at the provider. + private static RemedyAction Rerun(RemedyFacts facts, CallerStanding caller) + { + var fixed_ = facts.Resolved || !facts.FixIsObservable; + var enabled = fixed_ && caller.MayLaunchRuns; + + return new RemedyAction { Kind = RemedyActionKind.Rerun, Label = "Re-run", Enabled = enabled, DisabledReason = enabled ? null : !fixed_ ? "Fix the connection first, then run it again." : "Needs the Member role or above." }; + } + + private const string NeedsAdmin = "Needs the Admin or Owner role."; + + /// + /// What the remedy has to act on — whether a credential, repositories and a provider connection could be named, and whether a + /// run exists to run again (a refused trigger delivery has none). Actions that would have nothing to act on are not offered. + /// + public sealed record ActionSubjects(bool HasCredential, bool HasRepositories, bool HasProviderInstance, bool OffersRerun); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyCopy.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyCopy.cs new file mode 100644 index 000000000..7f2a430bc --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyCopy.cs @@ -0,0 +1,120 @@ +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +/// +/// The words of a remedy — a title and a cause — written in plain language that names the credential and the repository, and +/// never a token, a status table or a stack. Pure over , so the sentence for every code is pinned by +/// a unit test and nothing else decides what a person is told. A fact that is not known is left out, not guessed at. +/// +/// The same copy serves a failed run and a refused trigger delivery: both are "this connection does not work", told for +/// where the person is standing. The codes are for a run and the refusal reasons of +/// for a delivery. +/// +public static class RemedyCopy +{ + public static string Title(string code, RemedyFacts facts) => Capitalise(facts.Resolved ? "This connection works again" : code switch + { + FailureCodes.CredentialDisconnected => facts.CredentialRetired ? "The credential this run used was retired" : "The credential this run used was disconnected", + FailureCodes.CredentialNeedsReconnect => $"{Provider(facts)} no longer accepts the credential this run used", + FailureCodes.ProviderUnauthorized => $"{Provider(facts)} rejected the credential for this repository", + FailureCodes.GitCredentialRejected => $"{Provider(facts)} refused the push", + FailureCodes.ProviderBaseUrlRedirects => "The provider's address redirects somewhere else", + FailureCodes.OAuthExchangeFailed => "Renewing the sign-in this run used did not work", + WorkflowRunRequestRejectionReasons.CredentialRejected => $"{Provider(facts)} refused the credential this trigger uses", + WorkflowRunRequestRejectionReasons.AuthorStandingUnknown => StandingTitle(facts), + _ => "A provider connection needs attention" + }); + + public static string Cause(string code, RemedyFacts facts) => Capitalise(facts.Resolved ? ResolvedCause(code, facts) : code switch + { + FailureCodes.CredentialDisconnected => Disconnected(facts), + FailureCodes.CredentialNeedsReconnect => NeedsReconnect(facts), + FailureCodes.ProviderUnauthorized => Unauthorized(facts), + FailureCodes.GitCredentialRejected => $"The agent finished, but {Provider(facts)} refused {Credential(facts)} when its branch was pushed to {Repository(facts)}, so nothing was published. Reconnect the credential, or link {Repository(facts)} to one that works, then run it again.", + FailureCodes.ProviderBaseUrlRedirects => Redirects(facts), + FailureCodes.OAuthExchangeFailed => RenewalUnanswered(facts), + WorkflowRunRequestRejectionReasons.CredentialRejected => $"{Provider(facts)} refused {Credential(facts)}, so CodeSpace could not check who wrote the pull request on {Repository(facts)}. The author is not in question — the credential is. Reconnect it, or link {Repository(facts)} to one that works, and the next pull request event starts the run.", + WorkflowRunRequestRejectionReasons.AuthorStandingUnknown => StandingUnknown(facts), + _ => "Something about a provider connection stopped this from working." + }); + + private static string Disconnected(RemedyFacts facts) => facts.CredentialRetired + ? $"{Credential(facts)} was retired — its owner left the team or its connection was removed — so it cannot be reconnected. {Repository(facts)} has to be linked to a credential that works before anything can run through it." + : $"{Credential(facts)} was disconnected, so {Repository(facts)} cannot authenticate with {Provider(facts)}. Reconnect it to put it back, or link {Repository(facts)} to a credential that works."; + + private static string NeedsReconnect(RemedyFacts facts) => facts.CredentialIsStaticToken + ? $"{Provider(facts)} rejected the token behind {Credential(facts)} — it was revoked or has expired — so {Repository(facts)} cannot authenticate. Paste a new token into it and nothing bound through it has to change." + : $"{Provider(facts)} refused to renew the sign-in behind {Credential(facts)} — it was revoked, or the app's access was withdrawn — so {Repository(facts)} cannot authenticate. Reconnect it and nothing bound through it has to change."; + + private static string Unauthorized(RemedyFacts facts) + { + var status = facts.ProviderStatus is { } code ? $" (HTTP {code})" : ""; + var what = facts.CredentialIsStaticToken ? "The token may have been revoked, may have expired, or may have lost the access it needs." : "The sign-in may have been revoked, or may have lost the access it needs."; + + return $"{Provider(facts)} rejected {Credential(facts)} when it was used for {Repository(facts)}{status}. {what} Replace or reconnect it, or link {Repository(facts)} to a credential that works."; + } + + private static string Redirects(RemedyFacts facts) + { + var from = facts.RequestedOrigin ?? facts.InstanceName ?? "the provider"; + var to = facts.RedirectsTo ?? "another address"; + var notFollowed = "A connected token is only ever sent to the address it was connected for, so the redirect was not followed."; + + return facts.RedirectFix switch + { + RedirectRepair.HostSide => $"{Provider(facts)} answered a redirect from {from} that was not followed: the request changes something, or the redirects do not end, and a request is never sent again blindly. The address CodeSpace has is not what is wrong — remove the redirect on the host, or ask whoever runs it.", + RedirectRepair.DisconnectFirst => $"Requests to {from} are redirected to {to}, which is another host or port. {notFollowed} The provider's address cannot be changed to {to} while {Plural(facts.ConnectedCredentials, "credential")} {(facts.ConnectedCredentials == 1 ? "is" : "are")} connected through it, because a connected token never moves to another host. Disconnect them first and then change the address, or add {to} as a provider of its own and link the repositories to a credential there, or remove the redirect on the host.", + _ => $"Requests to {from} are redirected to {to}. {notFollowed} Change the provider's address to {to}, or remove the redirect on the host." + }; + } + + private static string Plural(int count, string noun) => count == 1 ? $"1 {noun}" : $"{count} {noun}s"; + + /// A renewal that failed under a run is never a final refusal (that is credential_needs_reconnect): the provider was down, or answered something that is not OAuth. It says nothing about the grant, so the sentence does not blame the credential. + private static string RenewalUnanswered(RemedyFacts facts) + { + var answer = facts.OAuthError is { Length: > 0 } error ? $" (it answered {error})" : ""; + + return $"{Provider(facts)} did not renew the sign-in behind {Credential(facts)} when {Repository(facts)} needed it{answer}. That says nothing about the credential itself — a refusal of the grant would have said so — so there is nothing to repair yet. Run it again; if it keeps failing, reconnect it."; + } + + private static string StandingTitle(RemedyFacts facts) => facts.Lookup switch + { + StandingLookupCauses.NoAnswer => $"{Provider(facts)} could not be asked who wrote the pull request", + StandingLookupCauses.NoCredential => $"There is no credential to ask {Provider(facts)} who wrote the pull request with", + _ => $"{Provider(facts)} would not say who wrote the pull request" + }; + + /// What the lookup that failed was, when the refusal says: the credential may not read members (reconnect or replace it), the provider did not answer (nothing to repair), the repository has no credential (link one). A row that does not say words it as either. + private static string StandingUnknown(RemedyFacts facts) + { + var status = facts.ProviderStatus is { } code ? $" (HTTP {code})" : ""; + var notTheFix = "Setting “Pull requests from” to Anyone is not the fix: nothing was learned about the author."; + + return facts.Lookup switch + { + StandingLookupCauses.AccessRefused => $"{Provider(facts)} would not answer who wrote the pull request{status}. {Credential(facts)} lacks the read_api scope or a role on the project to read its members — reconnect or replace it with one that has both. {notTheFix}", + StandingLookupCauses.NoAnswer => $"{Provider(facts)} could not be asked who wrote the pull request on {Repository(facts)}: it did not answer in time, or answered with an error{status}. That is not a verdict on {Credential(facts)}, so there is nothing to repair; the next pull request event asks again. {notTheFix}", + StandingLookupCauses.NoCredential => $"No credential is linked to {Repository(facts)}, so {Provider(facts)} was not asked who wrote the pull request. Link it to one that has the read_api scope and a role on the project, and the next pull request event asks again. {notTheFix}", + _ => $"{Provider(facts)} would not answer who wrote the pull request{status}. Usually {Credential(facts)} lacks the read_api scope or a role on the project to read its members; it can also be a timeout or an error on {Provider(facts)}'s side that clears by itself. The newest refused delivery below says which. If it keeps happening, reconnect or replace the credential with one that has both. {notTheFix}" + }; + } + + private static string ResolvedCause(string code, RemedyFacts facts) => code switch + { + FailureCodes.ProviderBaseUrlRedirects => "The provider's address no longer redirects. Run it again to pick up where this stopped.", + WorkflowRunRequestRejectionReasons.CredentialRejected or WorkflowRunRequestRejectionReasons.AuthorStandingUnknown => $"{Credential(facts)} works again, so the next pull request event on {Repository(facts)} starts its run.", + _ => $"{Credential(facts)} works again, so {Repository(facts)} can authenticate. Run it again to pick up where this stopped." + }; + + /// The provider as a person says it, or "the provider" when the failure did not say which — capitalised where it starts a sentence. + private static string Provider(RemedyFacts facts) => facts.Provider ?? "the provider"; + + private static string Capitalise(string sentence) => sentence.Length == 0 ? sentence : char.ToUpperInvariant(sentence[0]) + sentence[1..]; + + private static string Credential(RemedyFacts facts) => facts.CredentialName is { Length: > 0 } name ? $"“{name}”" : "the credential"; + + private static string Repository(RemedyFacts facts) => facts.Repositories ?? "the repository"; +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyFacts.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyFacts.cs new file mode 100644 index 000000000..0cc0024ed --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyFacts.cs @@ -0,0 +1,77 @@ +namespace CodeSpace.Core.Services.Credentials.Remedy; + +/// +/// The specifics a remedy's words and actions are built from, already read and already resolved to text — so the wording is a pure +/// function of them and a unit test can pin every code's sentence without a database. A fact that is not known is null, and the +/// sentence leaves it out rather than inventing it. +/// +public sealed record RemedyFacts +{ + /// The provider's name as a person says it: GitLab, GitHub, Git. Null when the failure did not say which, and the sentence says "the provider". + public string? Provider { get; init; } + + public string? CredentialName { get; init; } + + /// The person the credential belongs to; null for a team-service credential. + public string? OwnerName { get; init; } + + /// The credential is a person's own and the asker is that person. + public bool CallerOwnsCredential { get; init; } + + /// Soft-deleted: it cannot be reconnected, only replaced by moving what leans on it. + public bool CredentialRetired { get; init; } + + /// A token a person pasted (personal, project or group), as opposed to a sign-in. Decides Reconnect versus Replace token. + public bool CredentialIsStaticToken { get; init; } + + /// Whether the credential is a team's own, owned by no person. + public bool CredentialIsTeamService { get; init; } + + /// The repositories the failure was about, as a phrase: one path, two paths, or "3 repositories". Null when none could be named. + public string? Repositories { get; init; } + + public int RepositoryCount { get; init; } + + /// The origin the provider's address redirected to, for a redirect failure. + public string? RedirectsTo { get; init; } + + /// The origin the credential was connected for, for a redirect failure. + public string? RequestedOrigin { get; init; } + + /// For a redirect: the repair the server would ACCEPT (), which decides whether an edit button is offered and what the sentence says to do. Null when the provider connection could not be named. + public RedirectRepair? RedirectFix { get; init; } + + /// For a redirect: how many credentials are connected through the provider — the ones an edit of its host would send somewhere new, and the reason the server refuses it. + public int ConnectedCredentials { get; init; } + + /// The provider connection's own name, when one is known. + public string? InstanceName { get; init; } + + /// What the provider answered (a status), when the failure carried it. + public string? ProviderStatus { get; init; } + + /// For a renewal that failed: the provider's OAuth error code (http_503, temporarily_unavailable). Under a run a final refusal is credential_needs_reconnect, so this is always an answer that says nothing about the grant. + public string? OAuthError { get; init; } + + /// For a refused trigger delivery that could not read an author's standing: which of failed. Null for a row written before it was kept, which the sentence then words as either. + public string? Lookup { get; init; } + + /// What the failure names works now. + public bool Resolved { get; init; } + + /// Whether a fix would show in what is recorded (). When it would not, "run it again" is not held back waiting for proof that cannot appear. + public bool FixIsObservable { get; init; } = true; +} + +/// What can actually put a redirecting provider right, given what the server will let be edited. +public enum RedirectRepair +{ + /// Setting the base URL to where the provider points is accepted: nothing is connected through it, or the change is an http to https upgrade of the same host that connected tokens may follow. + EditAddress, + + /// The address points at another host, scheme or port and credentials are connected, which the server refuses to move: they are disconnected first, or the new address is added as a provider of its own. + DisconnectFirst, + + /// Nothing CodeSpace can edit: the redirect stays inside one origin (a request that changes something, redirects that do not end), or it named no address. + HostSide +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyResolution.cs b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyResolution.cs new file mode 100644 index 000000000..c6a77752e --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/Remedy/RemedyResolution.cs @@ -0,0 +1,69 @@ +using CodeSpace.Core.Services.ProviderInstances; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Credentials.Remedy; + +/// +/// Whether what a failure names works NOW — the half of a remedy that turns a repair page into a "run it again" page. Pure over +/// what is recorded about the credential and when the failure was recorded, so the rule is one definition and a unit test can walk +/// every shape of "fixed", "looks fine but was never fixed" and "gone for good". +/// +public static class RemedyResolution +{ + /// What is recorded about a credential that decides whether it works; read from the row, never from the provider. + public sealed record CredentialState(Guid Id, CredentialStatus Status, AuthType AuthType, DateTimeOffset? ExpiresDate, DateTimeOffset? LastValidatedDate, bool Retired); + + /// The credential can authenticate as far as CodeSpace can tell: it is in service, not marked refused or disconnected, and a static token's recorded expiry has not passed. + public static bool IsLive(CredentialState credential, DateTimeOffset now) => + !credential.Retired + && credential.Status == CredentialStatus.Active + && CredentialHealthDerivation.Derive(credential.Status, credential.AuthType, credential.ExpiresDate, ownerLeft: false, now) is CredentialHealth.Active or CredentialHealth.Expiring; + + /// + /// The credential the failure was about is fixed. For a failure that met a credential the row already said was dead + /// (disconnected, refused) a live row IS the fix. For one that met a refusal the row did not know about (a 401, a refused push) + /// the row never said otherwise, so only something that validated it since the failure counts — a reconnect, a replaced token, + /// a passing probe. + /// + public static bool FailingCredentialIsFixed(CredentialState credential, string code, DateTimeOffset failedAt, DateTimeOffset now) + { + if (!IsLive(credential, now)) return false; + + return FixIsObservable(code) || credential.LastValidatedDate >= failedAt; + } + + /// Whether CodeSpace can SEE a fix for this code. A dead credential coming back, or an address no longer redirecting, shows in what is recorded; a provider's 401 on an Active row does not, so those remedies leave "run it again" available rather than demand proof nobody can give. + public static bool FixIsObservable(string code) => code is FailureCodes.CredentialDisconnected or FailureCodes.CredentialNeedsReconnect or FailureCodes.ProviderBaseUrlRedirects; + + /// + /// A repository authenticates through something that works. The credential it runs through is the failing one only when it + /// IS the one the failure named (or none was named); a different one is a replacement, and a live replacement works. + /// + public static bool RepositoryWorks(CredentialState? current, Guid? failingCredentialId, string code, DateTimeOffset failedAt, DateTimeOffset now) + { + if (current is null) return false; + + return failingCredentialId is { } failing && current.Id != failing ? IsLive(current, now) : FailingCredentialIsFixed(current, code, failedAt, now); + } + + /// A redirect is resolved once the provider's base URL no longer starts at the origin that redirected: it was edited to somewhere else. + public static bool RedirectIsResolved(string? requestedOrigin, string currentBaseUrl) => + !string.IsNullOrEmpty(requestedOrigin) && Uri.TryCreate(currentBaseUrl, UriKind.Absolute, out var current) && !string.Equals(Origin(current), requestedOrigin, StringComparison.OrdinalIgnoreCase); + + /// + /// The repair that will be ACCEPTED for a provider whose address redirects — the edit the card would offer is "set the base URL to + /// ", and the server refuses to move a provider's host while credentials are connected through it + /// (, the same rule the update runs, so the two cannot disagree), allowing only + /// an http to https upgrade of the same host. A redirect that stays inside one origin has no address to edit to at all. + /// + public static RedirectRepair RepairRedirect(string? requestedOrigin, string? redirectsTo, string baseUrl, string? apiUrl, int connectedCredentials) + { + if (string.IsNullOrEmpty(redirectsTo) || string.Equals(requestedOrigin, redirectsTo, StringComparison.OrdinalIgnoreCase)) return RedirectRepair.HostSide; + + return connectedCredentials > 0 && ProviderInstanceService.EndpointMoves(baseUrl, apiUrl, redirectsTo, apiUrl) ? RedirectRepair.DisconnectFirst : RedirectRepair.EditAddress; + } + + private static string Origin(Uri uri) => $"{uri.Scheme}://{uri.IdnHost}{(uri.IsDefaultPort ? "" : ":" + uri.Port)}"; +} diff --git a/backend/src/CodeSpace.Core/Services/OAuth/OAuthExchangeException.cs b/backend/src/CodeSpace.Core/Services/OAuth/OAuthExchangeException.cs index f7fa9cfeb..16dad02dd 100644 --- a/backend/src/CodeSpace.Core/Services/OAuth/OAuthExchangeException.cs +++ b/backend/src/CodeSpace.Core/Services/OAuth/OAuthExchangeException.cs @@ -45,8 +45,18 @@ private OAuthExchangeException(OAuthExchangeException original, Guid credentialI Error = original.Error; Description = original.Description; ProviderBody = original.ProviderBody; + CredentialId = credentialId; } + /// The credential whose refresh the provider answered, when it is known (); null for an exchange made before any credential existed. Carried in so a run that failed on this refresh can say which credential it was — and, with , whether the provider refused it or simply did not answer. + public Guid? CredentialId { get; } + + /// The credential and the provider's error CODE (http_503, temporarily_unavailable — never the description or the body, which are the provider's to write) once a credential is known; nothing before then. + public IReadOnlyDictionary? Details => CredentialId is { } credential ? new Dictionary { ["credentialId"] = credential, ["oauthError"] = IsCodeShaped(Error) ? Error : null } : null; + + /// An RFC 6749 error code is a short token (invalid_grant, http_503). What the provider wrote there is its own text, so only something shaped like a code is repeated in a detail a run keeps and a page shows. + private static bool IsCodeShaped(string error) => error.Length is > 0 and <= 64 && error.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-' or '.'); + public string Error { get; } public string? Description { get; } public string? ProviderBody { get; } diff --git a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomNarrative.cs b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomNarrative.cs index 9c20085c7..310f8c425 100644 --- a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomNarrative.cs +++ b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomNarrative.cs @@ -761,7 +761,10 @@ private static IReadOnlyList DeliveriesFrom(string idPrefix, long private static DiagnosticBlock RichDiagnostic(string idPrefix, long seq, WorkflowRunStatus status, string? error, RoomTurnFacts facts, IReadOnlyList narrativePhases) { var raw = facts.RawError ?? error; - var auth = IsAuthError(raw); + + // A provider failure the run recorded outranks the scan below: "the credential was disconnected" contains the word the scan + // reads as a rejected model key, and telling a person to fix their MODEL credentials for a git connection is exactly wrong. + var auth = facts.Failure is null && IsAuthError(raw); // Humanize the DEEP error (raw), not the generic run-row error — so the headline is the specific cause // ("OpenAI API error … the request timed out") the projector surfaced, not the "Node 'sup' failed." placeholder. @@ -775,12 +778,13 @@ private static DiagnosticBlock RichDiagnostic(string idPrefix, long seq, Workflo Id = $"{idPrefix}:diagnostic", Seq = seq, Tone = NarrativeTone.Error, - Title = auth ? "Authentication failed" : null, + Title = auth ? "Authentication failed" : facts.Failure is { } failure ? Credentials.Remedy.RemedyCopy.Title(failure.Code, new Credentials.Remedy.RemedyFacts()) : null, Text = text, Actions = auth ? new[] { new RoomAction { Kind = RoomActionKind.FixCredentials, Label = "Fix credentials", Enabled = true } } : Array.Empty(), RawDetail = raw is { Length: > 0 } r && r != text ? r : null, + FailureCode = facts.Failure?.Code, }; } diff --git a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs index a33af5108..d031ff81e 100644 --- a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs +++ b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomProjector.cs @@ -12,6 +12,7 @@ using CodeSpace.Core.Services.Supervisor; using CodeSpace.Core.Services.Tasks.Phases; using CodeSpace.Core.Services.Tasks.Timeline.Sources; +using CodeSpace.Core.Services.Workflows.Failures; using CodeSpace.Core.Services.Workflows; using CodeSpace.Core.Services.Workflows.Artifacts; using CodeSpace.Core.Services.Workflows.Budget; @@ -54,8 +55,9 @@ internal sealed class RoomProjector : IRoomProjector, IScopedDependency private readonly IRunCleanupLedger _cleanup; private readonly CodeSpaceDbContext _db; private readonly ISessionTurnCache _cache; + private readonly IRunFailureReader _failures; - public RoomProjector(ISessionSkeletonReader sessions, IRunPhaseProjector phases, IDecisionQueueService decisions, IRunActionCapabilityResolver actions, ISupervisorDecisionObservationBundle decisionObservations, IWorkPlanChecklistService checklists, IPublishManifestStore manifests, IArtifactManifestStore producedFiles, ISupervisorPublishedBranchResolver publishedBranches, IArtifactRangeReader artifacts, ITeamCostService costs, IBudgetLedger budget, ITeamCostCapResolver teamCaps, IRunCleanupLedger cleanup, CodeSpaceDbContext db, ISessionTurnCache cache) + public RoomProjector(ISessionSkeletonReader sessions, IRunPhaseProjector phases, IDecisionQueueService decisions, IRunActionCapabilityResolver actions, ISupervisorDecisionObservationBundle decisionObservations, IWorkPlanChecklistService checklists, IPublishManifestStore manifests, IArtifactManifestStore producedFiles, ISupervisorPublishedBranchResolver publishedBranches, IArtifactRangeReader artifacts, ITeamCostService costs, IBudgetLedger budget, ITeamCostCapResolver teamCaps, IRunCleanupLedger cleanup, CodeSpaceDbContext db, ISessionTurnCache cache, IRunFailureReader failures) { _sessions = sessions; _phases = phases; @@ -73,6 +75,7 @@ public RoomProjector(ISessionSkeletonReader sessions, IRunPhaseProjector phases, _cleanup = cleanup; _db = db; _cache = cache; + _failures = failures; } public async Task ProjectByRunAsync(Guid runId, Guid teamId, CancellationToken cancellationToken) @@ -696,6 +699,12 @@ private async Task GatherFactsAsync(Guid runId, Guid teamId, IRea ? await DeepFailureErrorAsync(runId, cancellationToken).ConfigureAwait(false) : null; + // The provider failure the run recorded, when it is one a person can repair outside the run. Static (a code and what it names), so + // it belongs in the cached turn; the card it points at — what to do, who may — is read live, for whoever is looking. + var failure = status == Messages.Enums.WorkflowRunStatus.Failure + ? (await _failures.ReadAsync(runId, teamId, cancellationToken).ConfigureAwait(false))?.Failure + : null; + // The stage the repository policy put out of reach, through the completion authority's OWN reader — so the // Room and the authority can never word one run's patch-only finish differently. Only a TERMINAL turn asks // (a live run has not finished integrating anything yet), so a running turn pays zero extra query. @@ -728,6 +737,7 @@ await VerificationOf(runId, status, verdict, acceptance, SupervisorOutcome.ReadA AcceptancePassed = acceptance, Deliveries = deliveries, RawError = deepError ?? error, + Failure = failure, PolicyBoundedStage = policyBoundedStage, RetrySteps = retrySteps, RespawnSteps = respawnSteps, diff --git a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomTurnFacts.cs b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomTurnFacts.cs index 92f157fa6..1566285e8 100644 --- a/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomTurnFacts.cs +++ b/backend/src/CodeSpace.Core/Services/Sessions/Room/RoomTurnFacts.cs @@ -83,6 +83,9 @@ public sealed record RoomTurnFacts /// public bool CompletionParked { get; init; } + /// The provider failure the run recorded — a connection disconnected, refused or redirecting — when it ended on one. Decides the diagnostic: such a failure is not a model credential problem, so it is never told as one, and it carries its code so the page can ask for the live remedy. Null for every other failure. + public Messages.Failures.PersistedFailure? Failure { get; init; } + /// The raw engine error, surfaced behind "Show raw error" on a failure diagnostic. Null when there's nothing rawer than the humanized text. public string? RawError { get; init; } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs b/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs index c126ca945..5c24504e7 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Engine/WorkflowEngine.cs @@ -4,6 +4,7 @@ using Autofac; using CodeSpace.Core.Constants; using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Failures; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents; @@ -449,7 +450,7 @@ private async Task MarkBootstrapFailureAsync(Guid runId, DateTimeOffset engineSt // The bootstrap terminal is the same invariant as every other terminal: the authoritative row and the // matching tape fact become visible together. If ledger persistence fails, rollback leaves Running for the // abandoned-run reconciler instead of publishing a terminal state whose timeline can never explain it. - await _recordLogger.RunFailedAsync(runId, message, now - engineStartedAt, CancellationToken.None).ConfigureAwait(false); + await _recordLogger.RunFailedAsync(runId, message, now - engineStartedAt, ProviderFailureCapture.From(failure, repositoryId: null), CancellationToken.None).ConfigureAwait(false); await transaction.CommitAsync(CancellationToken.None).ConfigureAwait(false); } @@ -3394,14 +3395,14 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit } catch (SubworkflowStartException ex) { - return await FinalizeFailureAsync(exec, ex.Message, cancellationToken).ConfigureAwait(false); + return await FinalizeFailureAsync(exec, ex.Message, thrown: null, cancellationToken).ConfigureAwait(false); } catch (AgentRunAdmissionException ex) { // The fail-closed admission cap refused this agent run at staging — a clean node failure (like a // failed sub-workflow start), so the agent.run node routes to its error edge and, inside a map // branch, the map's continue-on-error / terminate policy applies. NOT a crash, NOT a leaked wait. - return await FinalizeFailureAsync(exec, ex.Message, cancellationToken).ConfigureAwait(false); + return await FinalizeFailureAsync(exec, ex.Message, thrown: null, cancellationToken).ConfigureAwait(false); } } @@ -3421,7 +3422,7 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit retryable = retryable && (result?.Retryable ?? true); if (!retryable || attempt >= plan.MaxAttempts) - return await FinalizeFailureAsync(exec, lastError, cancellationToken).ConfigureAwait(false); + return await FinalizeFailureAsync(exec, lastError, thrown ?? result?.Cause, cancellationToken).ConfigureAwait(false); await LogRetryAndWaitAsync(exec, attempt, plan, lastError, attemptStartedAt, retryAfter, cancellationToken).ConfigureAwait(false); @@ -3439,7 +3440,7 @@ private async Task ExecuteNodeAsync(WorkflowRun run, NodeDefinit } // Defensive: the loop always returns on its final attempt. - return await FinalizeFailureAsync(exec, lastError, cancellationToken).ConfigureAwait(false); + return await FinalizeFailureAsync(exec, lastError, thrown: null, cancellationToken).ConfigureAwait(false); } /// @@ -3593,12 +3594,18 @@ private async Task FinalizeNonFailureAsync(NodeExecution exec, N /// once the walker merges it. The scope write happens in ; it's /// harmless when the node has no error edge — the run fails and scope is dropped. /// - private async Task FinalizeFailureAsync(NodeExecution exec, string error, CancellationToken cancellationToken) + private async Task FinalizeFailureAsync(NodeExecution exec, string error, Exception? thrown, CancellationToken cancellationToken) { - await _recordLogger.NodeFailedAsync(exec.Run.Id, exec.Node.Id, exec.IterationKey, RedactForPersistence(exec.Scope, error), DateTimeOffset.UtcNow - exec.StartedAt, cancellationToken).ConfigureAwait(false); + var failure = ProviderFailureCapture.From(thrown, RepositoryActedOn(exec)); + + await _recordLogger.NodeFailedAsync(exec.Run.Id, exec.Node.Id, exec.IterationKey, RedactForPersistence(exec.Scope, error), DateTimeOffset.UtcNow - exec.StartedAt, failure, cancellationToken).ConfigureAwait(false); return new NodeRunOutcome(NodeStatus.Failure, BuildErrorOutput(error, exec.Node.Id), null, null); } + /// The repository the node was acting on, when it declares an input for one and the input resolved to an id — read off the manifest's own declaration, so no node is named here. Lets a provider failure that names no credential (a 401) be traced to the one the repository runs through. + private static Guid? RepositoryActedOn(NodeExecution exec) => + exec.Runtime.Manifest.RepositoryInput is { } spec && exec.ResolvedInputs.TryGetValue(spec.InputKey, out var value) && value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id) ? id : null; + /// /// The node's error output bag — { "error": { "message": ..., "node": ... } }, /// referenced by a handler as {{nodes.<id>.outputs.error.message}} / .error.node. diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Failures/IRunFailureReader.cs b/backend/src/CodeSpace.Core/Services/Workflows/Failures/IRunFailureReader.cs new file mode 100644 index 000000000..e44377cbd --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/Failures/IRunFailureReader.cs @@ -0,0 +1,17 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Workflows.Failures; + +/// +/// Which provider failure, if any, a run is recorded as having ended on. ONE definition of that answer, asked by the run page's +/// remedy and by the room's diagnostic, so the card and the code beside it can never name different failures. +/// +public interface IRunFailureReader +{ + /// + /// The failure the run ended on, or null: it recorded none, it is not the team's, or it did not fail on a provider connection. + /// For a failed run the node's or run's own record wins and the last agent run's result is the fallback; for a run that did + /// not fail, only a push the remote refused counts — the run stayed whole, and its branch was not published. + /// + Task ReadAsync(Guid runId, Guid teamId, CancellationToken cancellationToken); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Failures/PersistedFailureJson.cs b/backend/src/CodeSpace.Core/Services/Workflows/Failures/PersistedFailureJson.cs new file mode 100644 index 000000000..ccfc3b85f --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/Failures/PersistedFailureJson.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Workflows.Failures; + +/// +/// How a sits in a ledger record's payload: an additive failure object beside the +/// error sentence it explains. A record written before failures were kept simply has no such key, and reads as +/// "no failure" — the sentence keeps rendering as it always did. +/// +public static class PersistedFailureJson +{ + /// The payload key the object is written under. + public const string PayloadKey = "failure"; + + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + + /// The object to put under . + public static JsonElement ToElement(PersistedFailure failure) => JsonSerializer.SerializeToElement(failure, Options); + + /// A payload holding only the failure — {"failure": {...}} — for a row whose JSON column has no sentence beside it, read back by . + public static string AsPayload(PersistedFailure failure) => JsonSerializer.Serialize(new Dictionary { [PayloadKey] = ToElement(failure) }); + + /// The failure a record's payload carries, or null when it carries none or what it carries cannot be read — never throws, because a record that cannot be read must degrade to the sentence, not fail the page that shows it. + public static PersistedFailure? Read(string? payloadJson) + { + if (string.IsNullOrEmpty(payloadJson)) return null; + + try + { + using var document = JsonDocument.Parse(payloadJson); + + return document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty(PayloadKey, out var failure) ? ReadElement(failure) : null; + } + catch (JsonException) { return null; } + } + + /// The failure an element holds (an agent result's failure, or a record's), or null when it is not one. + public static PersistedFailure? ReadElement(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) return null; + + try { return element.Deserialize(Options) is { Code.Length: > 0 } failure ? failure : null; } + catch (JsonException) { return null; } + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Failures/RunFailureReader.cs b/backend/src/CodeSpace.Core/Services/Workflows/Failures/RunFailureReader.cs new file mode 100644 index 000000000..25f3f54ee --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/Failures/RunFailureReader.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Workflows.Failures; + +/// +/// Reads the provider failure a run ended on from the two places it is written: the run's own ledger (a node that threw it, a +/// run that could not start) and the agent run's stored result (a clone that could not authenticate, a push the remote refused). +/// Bounded, and never throws on a record it cannot read — an old row degrades to "no failure", which keeps the page rendering the +/// sentence it always did. +/// +/// It answers for the node that ENDED the run, and for nothing earlier. A run walks past failures it handles (an error +/// edge), so an older record that names a provider failure says only that the run once met one; the run's own error names the node +/// that failed it (Node 'x' failed.), and that node's record decides — a failure carried there, else the agent run it spawned. +/// A node that failed for another reason, or a run ended by the reconciler, answers null rather than the first failure found. +/// +public sealed class RunFailureReader : IRunFailureReader, IScopedDependency +{ + /// How many of a run's failure records are looked through for one that names a failure. A run records a handful; the bound is for a map that failed a thousand branches. + private const int MaxRecordScan = 40; + + private readonly CodeSpaceDbContext _db; + + public RunFailureReader(CodeSpaceDbContext db) { _db = db; } + + public async Task ReadAsync(Guid runId, Guid teamId, CancellationToken cancellationToken) + { + var run = await LoadRunAsync(runId, teamId, cancellationToken).ConfigureAwait(false); + + if (run is null) return null; + + var found = run.Status == WorkflowRunStatus.Failure + ? await FromWhereTheRunEndedAsync(runId, teamId, run.Error, cancellationToken).ConfigureAwait(false) + : await FromLatestAgentRunAsync(runId, teamId, nodeId: null, publishRefusalOnly: true, cancellationToken).ConfigureAwait(false); + + return found is null ? null : new RecordedFailure { Failure = found.Value.Failure, At = found.Value.At, ScopeRepositoryIds = run.ScopeRepositoryIds }; + } + + private async Task LoadRunAsync(Guid runId, Guid teamId, CancellationToken cancellationToken) => + await _db.WorkflowRun.AsNoTracking() + .Where(r => r.Id == runId && r.TeamId == teamId) + .Select(r => new RunHeader(r.Status, r.ScopeRepositoryIds, r.Error)) + .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + /// The failure the node that ended the run recorded; failing that, its agent run's own account. Where the ledger names no node that ended the run, nothing speaks for it — except a ledger with no node failure at all, which leaves the agent run as the only witness. + private async Task<(PersistedFailure Failure, DateTimeOffset At)?> FromWhereTheRunEndedAsync(Guid runId, Guid teamId, string? runError, CancellationToken cancellationToken) + { + var ended = await ReadLedgerEndAsync(runId, runError, cancellationToken).ConfigureAwait(false); + + if (ended.Failure is { } failure) return (failure, ended.At); + + return ended.NodeFailuresRecorded && ended.NodeId is null ? null : await FromLatestAgentRunAsync(runId, teamId, ended.NodeId, publishRefusalOnly: false, cancellationToken).ConfigureAwait(false); + } + + /// + /// Newest first: a run.failed that carries a failure of its own (a run that could not start) is the answer; one that + /// does not only restates, in a sentence, the node that failed it, so it is passed over to the node the run's error names. A + /// node that failed and was handled, or failed inside a branch that did not end the run, is not that node and is passed over. + /// + private async Task ReadLedgerEndAsync(Guid runId, string? runError, CancellationToken cancellationToken) + { + var rows = await _db.WorkflowRunRecord.AsNoTracking() + .Where(r => r.RunId == runId && (r.RecordType == WorkflowRunRecordTypes.NodeFailed || r.RecordType == WorkflowRunRecordTypes.RunFailed)) + .OrderByDescending(r => r.Sequence) + .Select(r => new { r.RecordType, r.NodeId, r.PayloadJson, r.OccurredAt }) + .Take(MaxRecordScan) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + foreach (var row in rows) + { + var failure = PersistedFailureJson.Read(row.PayloadJson); + + if (row.RecordType == WorkflowRunRecordTypes.RunFailed) + { + if (failure is not null) return new LedgerEnd(failure, row.OccurredAt, null, true); + + continue; + } + + if (!NamesNode(runError, row.NodeId)) continue; + + return new LedgerEnd(failure, row.OccurredAt, row.NodeId, true); + } + + return new LedgerEnd(null, default, null, rows.Any(r => r.RecordType == WorkflowRunRecordTypes.NodeFailed)); + } + + /// The walker ends a run with Node 'x' failed.; a run whose error says nothing (none recorded) leaves every node a candidate. + private static bool NamesNode(string? runError, string? nodeId) => string.IsNullOrEmpty(runError) || (nodeId is not null && runError.Contains($"'{nodeId}'", StringComparison.Ordinal)); + + /// + /// The latest agent run's own account — of 's runs when the ledger named the node that ended the run, since + /// another node's agent run is not why this one ended. Only the LAST one: an earlier attempt that met a disconnected credential + /// and was respawned says nothing about why the run ended. narrows it to the one failure a + /// run that finished can carry — its branch was refused by the remote. + /// + private async Task<(PersistedFailure Failure, DateTimeOffset At)?> FromLatestAgentRunAsync(Guid runId, Guid teamId, string? nodeId, bool publishRefusalOnly, CancellationToken cancellationToken) + { + var latest = await _db.AgentRun.AsNoTracking() + .Where(a => a.WorkflowRunId == runId && a.TeamId == teamId && a.ResultJson != null && (nodeId == null || a.NodeId == nodeId)) + .OrderByDescending(a => a.CreatedDate) + .Select(a => new { a.ResultJson, At = a.CompletedAt ?? a.LastModifiedDate }) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + if (latest is null) return null; + + var failure = ReadAgentResult(latest.ResultJson!); + + if (failure is null || (publishRefusalOnly && failure.Code != FailureCodes.GitCredentialRejected)) return null; + + return (failure, latest.At); + } + + /// The failure an agent run's stored result names: its own failure, else a push refused for the credential — the single-repo result's, or a multi-repo result's first refused repository. + internal static PersistedFailure? ReadAgentResult(string resultJson) + { + try + { + using var document = JsonDocument.Parse(resultJson); + var result = document.RootElement; + + if (result.ValueKind != JsonValueKind.Object) return null; + if (result.TryGetProperty(PersistedFailureJson.PayloadKey, out var own) && PersistedFailureJson.ReadElement(own) is { } failure) return failure; + if (ReadString(result, "publishFailureCode") == FailureCodes.GitCredentialRejected) return Refused(repositoryId: null); + + return RefusedRepository(result); + } + catch (JsonException) { return null; } + } + + private static PersistedFailure? RefusedRepository(JsonElement result) + { + if (!result.TryGetProperty("repositoryResults", out var repositories) || repositories.ValueKind != JsonValueKind.Array) return null; + + foreach (var repository in repositories.EnumerateArray()) + if (repository.ValueKind == JsonValueKind.Object && ReadString(repository, "publishFailureCode") == FailureCodes.GitCredentialRejected) + return Refused(Guid.TryParse(ReadString(repository, "repositoryId"), out var id) ? id : null); + + return null; + } + + private static PersistedFailure Refused(Guid? repositoryId) => new() { Code = FailureCodes.GitCredentialRejected, RepositoryId = repositoryId }; + + private static string? ReadString(JsonElement bag, string key) => bag.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private sealed record RunHeader(WorkflowRunStatus Status, List ScopeRepositoryIds, string? Error); + + /// What the ledger says ended the run: the failure the node (or the run record) carries, when it carries one; which node that was, when one is named; and whether any node failed at all. + private sealed record LedgerEnd(PersistedFailure? Failure, DateTimeOffset At, string? NodeId, bool NodeFailuresRecorded); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/IRunRecordLogger.cs b/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/IRunRecordLogger.cs index 5897e45f1..f0d42d156 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/IRunRecordLogger.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/IRunRecordLogger.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CodeSpace.Messages.Failures; namespace CodeSpace.Core.Services.Workflows.Lifecycle; @@ -41,6 +42,13 @@ public interface IRunRecordLogger /// Emit run.failed. Called on NodeFailureException / WorkflowSecretLeakException; is the run-level message. Task RunFailedAsync(Guid runId, string error, TimeSpan duration, CancellationToken cancellationToken); + /// + /// run.failed that also keeps the failure the run ended on as a beside the sentence, so a + /// reader can tell WHAT failed without parsing . Defaults to the plain record, which is what an + /// implementer that has not adopted failures writes — the failure is then simply not kept, never an error. + /// + Task RunFailedAsync(Guid runId, string error, TimeSpan duration, PersistedFailure? failure, CancellationToken cancellationToken) => RunFailedAsync(runId, error, duration, cancellationToken); + /// Emit run.cancelled. Reserved for operator-cancel. Task RunCancelledAsync(Guid runId, TimeSpan duration, CancellationToken cancellationToken); @@ -74,6 +82,13 @@ public interface IRunRecordLogger /// Emit node.failed. is the human-readable failure reason. Task NodeFailedAsync(Guid runId, string nodeId, string iterationKey, string error, TimeSpan duration, CancellationToken cancellationToken); + /// + /// node.failed that also keeps the failure the node died of as a beside + /// — the machine-readable half the run page turns into a remedy. A null + /// writes exactly the plain record, byte for byte. Defaults to the plain record for an implementer that has not adopted it. + /// + Task NodeFailedAsync(Guid runId, string nodeId, string iterationKey, string error, TimeSpan duration, PersistedFailure? failure, CancellationToken cancellationToken) => NodeFailedAsync(runId, nodeId, iterationKey, error, duration, cancellationToken); + /// /// Emit attempt.failed — the durable, structured record of ONE failed-but-will-be-retried node attempt, /// chained to the node.started row via . A node-scoped SUB-event (NOT under diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/RunRecordLogger.cs b/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/RunRecordLogger.cs index fcd547706..fd98b9c0c 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/RunRecordLogger.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Lifecycle/RunRecordLogger.cs @@ -3,7 +3,9 @@ using CodeSpace.Core.Persistence; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows.Failures; using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Failures; using CodeSpace.Messages.Workflows; using Microsoft.EntityFrameworkCore; @@ -62,9 +64,14 @@ public async Task RunCompletedAsync(Guid runId, TimeSpan duration, bool outputsP await InsertAsync(runId, WorkflowRunRecordTypes.RunCompleted, nodeId: null, iterationKey: string.Empty, payload, correlationId: null, parentRecordId: null, cancellationToken).ConfigureAwait(false); } - public async Task RunFailedAsync(Guid runId, string error, TimeSpan duration, CancellationToken cancellationToken) + public async Task RunFailedAsync(Guid runId, string error, TimeSpan duration, CancellationToken cancellationToken) => await RunFailedAsync(runId, error, duration, failure: null, cancellationToken).ConfigureAwait(false); + + public async Task RunFailedAsync(Guid runId, string error, TimeSpan duration, PersistedFailure? failure, CancellationToken cancellationToken) { - var payload = JsonSerializer.Serialize(new { error, duration_ms = (long)duration.TotalMilliseconds }); + var payload = failure is null + ? JsonSerializer.Serialize(new { error, duration_ms = (long)duration.TotalMilliseconds }) + : JsonSerializer.Serialize(new { error, duration_ms = (long)duration.TotalMilliseconds, failure = PersistedFailureJson.ToElement(failure) }); + await InsertAsync(runId, WorkflowRunRecordTypes.RunFailed, nodeId: null, iterationKey: string.Empty, payload, correlationId: null, parentRecordId: null, cancellationToken).ConfigureAwait(false); } @@ -132,11 +139,18 @@ internal static string NodeCompletedPayload(IReadOnlyDictionary + await NodeFailedAsync(runId, nodeId, iterationKey, error, duration, failure: null, cancellationToken).ConfigureAwait(false); + + public async Task NodeFailedAsync(Guid runId, string nodeId, string iterationKey, string error, TimeSpan duration, PersistedFailure? failure, CancellationToken cancellationToken) { // outputs included as empty object for view-projection consistency (the view's // COALESCE on the latest record's payload->outputs needs the key to exist). - var payload = JsonSerializer.Serialize(new { error, outputs = EmptyObject(), duration_ms = (long)duration.TotalMilliseconds }); + // A failure a person can repair rides beside the sentence; without one the payload is byte-identical to before. + var payload = failure is null + ? JsonSerializer.Serialize(new { error, outputs = EmptyObject(), duration_ms = (long)duration.TotalMilliseconds }) + : JsonSerializer.Serialize(new { error, outputs = EmptyObject(), duration_ms = (long)duration.TotalMilliseconds, failure = PersistedFailureJson.ToElement(failure) }); + await InsertAsync(runId, WorkflowRunRecordTypes.NodeFailed, nodeId, iterationKey, payload, correlationId: null, parentRecordId: null, cancellationToken).ConfigureAwait(false); } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCloseIssueNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCloseIssueNode.cs index 4dd312806..c7e5755d5 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCloseIssueNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCloseIssueNode.cs @@ -95,7 +95,7 @@ public async Task RunAsync(NodeRunContext context, CancellationToken } catch (InvalidOperationException ex) { return NodeResult.Fail(ex.Message); } catch (ProviderInsufficientScopeException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number)); } - catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number)); } + catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number), ex); } context.Logger.LogInformation("Closed issue #{Num} on repo {RepoId} (state={State})", number, repoId, issue.State); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCommentIssueNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCommentIssueNode.cs index 9b9e2de53..a3eab0b7c 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCommentIssueNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCommentIssueNode.cs @@ -97,7 +97,7 @@ public async Task RunAsync(NodeRunContext context, CancellationToken } catch (InvalidOperationException ex) { return NodeResult.Fail(ex.Message); } catch (ProviderInsufficientScopeException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number)); } - catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number)); } + catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number), ex); } context.Logger.LogInformation("Posted comment {CommentId} on repo {RepoId} issue #{Num}", comment.ExternalId, repoId, number); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCreateIssueNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCreateIssueNode.cs index c2520c942..833cb885d 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCreateIssueNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitCreateIssueNode.cs @@ -105,7 +105,7 @@ public async Task RunAsync(NodeRunContext context, CancellationToken // node failure. Provider scope / permission / validation failures map to actionable text. catch (InvalidOperationException ex) { return NodeResult.Fail(ex.Message); } catch (ProviderInsufficientScopeException ex) { return NodeResult.Fail(DescribeWriteFailure(ex)); } - catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex)); } + catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex), ex); } context.Logger.LogInformation("Created issue #{Num} on repo {RepoId}", issue.Number, repoId); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs index 68adb42f0..07b03ea14 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitMergePullRequestNode.cs @@ -133,7 +133,7 @@ public async Task RunAsync(NodeRunContext context, CancellationToken } catch (PullRequestMovedException ex) { return NodeResult.Fail(DescribeMergeFailure(ex, number, input.ExpectedHeadSha)); } catch (ProviderInsufficientScopeException ex) { return NodeResult.Fail(DescribeMergeFailure(ex, number, input.ExpectedHeadSha)); } - catch (ProviderApiException ex) { return NodeResult.Fail(DescribeMergeFailure(ex, number, input.ExpectedHeadSha)); } + catch (ProviderApiException ex) { return NodeResult.Fail(DescribeMergeFailure(ex, number, input.ExpectedHeadSha), ex); } context.Logger.LogInformation("Merged PR #{Num} on repo {RepoId} (merged={Merged}, method {Method}, source branch {SourceBranchDeletion})", number, repoId, result.Merged, method, result.SourceBranchDeletion); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs index ff3d73bed..303f46d0a 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitOpenPullRequestNode.cs @@ -132,7 +132,7 @@ public async Task RunAsync(NodeRunContext context, CancellationToken // it as a clean node failure. Provider scope / permission / validation failures map to actionable text. catch (InvalidOperationException ex) { return NodeResult.Fail(ex.Message); } catch (ProviderInsufficientScopeException ex) { return NodeResult.Fail(DescribeWriteFailure(ex)); } - catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex)); } + catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex), ex); } context.Logger.LogInformation("Opened PR #{Num} on repo {RepoId} ({Source} -> {Target})", pr.Number, repoId, sourceBranch, targetBranch); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs index 5f6b324de..360d531d2 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/GitPrReviewNode.cs @@ -130,7 +130,7 @@ public async Task RunAsync(NodeRunContext context, CancellationToken // repo-level permission is only knowable here, at write time.) catch (PullRequestMovedException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number, expectedHeadSha)); } catch (ProviderInsufficientScopeException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number, expectedHeadSha)); } - catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number, expectedHeadSha)); } + catch (ProviderApiException ex) { return NodeResult.Fail(DescribeWriteFailure(ex, number, expectedHeadSha), ex); } context.Logger.LogInformation("Submitted {Verdict} review for repo {RepoId} PR #{Num}", verdict, repoId, number); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs index 27f7d539c..f39ba7645 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/NodeResult.cs @@ -23,6 +23,14 @@ public sealed record NodeResult /// Whether a Failure may be retried under the node's retry policy. Default true (the historical status-blind behaviour). A node marks a DETERMINISTIC failure false — a human-owed verdict, the user's own cancel — so the engine never burns attempts re-running work whose outcome cannot change. public bool Retryable { get; init; } = true; + /// + /// The exception a node turned into its own sentence, when it wants the failure behind the sentence kept: the + /// engine reads what the exception declares (a provider connection failure a person can repair) and writes it beside the + /// sentence, which stays exactly as the node worded it. Never read for retry: only a THROWN exception classifies a failure as + /// terminal, so a node that returns a failure with a cause is retried exactly as before. + /// + public Exception? Cause { get; init; } + /// Set when Status == Suspended. Opaque token the resume mechanism feeds back. public SuspensionToken? SuspendUntil { get; init; } @@ -44,6 +52,9 @@ public static NodeResult Route(IReadOnlyList handles, IReadOnlyDictionar public static NodeResult Fail(string error) => new() { Status = NodeStatus.Failure, Error = error }; + /// A failure worded by the node, keeping the exception it was worded from () so what it declared can be recorded. + public static NodeResult Fail(string error, Exception cause) => new() { Status = NodeStatus.Failure, Error = error, Cause = cause }; + /// A failure with an explicit retry verdict — false marks a deterministic outcome the retry policy must not re-run. public static NodeResult Fail(string error, bool retryable) => new() { Status = NodeStatus.Failure, Error = error, Retryable = retryable }; diff --git a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/Exceptions/PullRequestStandingUnavailableException.cs b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/Exceptions/PullRequestStandingUnavailableException.cs index e4fb9e093..2152627a7 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/Exceptions/PullRequestStandingUnavailableException.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/Exceptions/PullRequestStandingUnavailableException.cs @@ -13,12 +13,20 @@ namespace CodeSpace.Core.Services.Workflows.RunSources.Admission.Exceptions; /// public sealed class PullRequestStandingUnavailableException : Exception, IFailure { - public PullRequestStandingUnavailableException(string reason, string detail, string auditKey) : base(detail) + public PullRequestStandingUnavailableException(string reason, string detail, string auditKey, PersistedFailure? lookup = null) : base(detail) { Reason = reason; AuditKey = auditKey; + Lookup = lookup; } + /// + /// What failed, as the machine-readable half of : the refusal's reason as the code, the + /// cause and the status the provider answered as details. Kept on the audit row so the repository's + /// remedy can tell a credential that may not read members from a provider that did not answer. Null for a refusal blamed on the credential outright. + /// + public PersistedFailure? Lookup { get; } + /// The value the refusal is recorded under. public string Reason { get; } = WorkflowRunRequestRejectionReasons.AuthorStandingUnknown; diff --git a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/PullRequestTriggerAdmission.cs b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/PullRequestTriggerAdmission.cs index 38b83bce1..322463d21 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/PullRequestTriggerAdmission.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/Admission/PullRequestTriggerAdmission.cs @@ -11,6 +11,7 @@ using CodeSpace.Messages.Events; using CodeSpace.Messages.Events.PullRequest; using CodeSpace.Messages.Exceptions; +using CodeSpace.Messages.Failures; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -96,7 +97,7 @@ private void ThrowIfStandingUnavailable(WorkflowActivation activation, IPullRequ var audit = BuildRefusalAuditKey(failure.Reason == WorkflowRunRequestRejectionReasons.CredentialRejected ? "credential-rejected" : "standing-unknown", activation.Id, pullRequest, DateTimeOffset.UtcNow); - throw new PullRequestStandingUnavailableException(failure.Reason, StandingUnavailable(pullRequest, whose, failure), audit); + throw new PullRequestStandingUnavailableException(failure.Reason, StandingUnavailable(pullRequest, whose, failure), audit, failure.Lookup); } /// The pusher's standing is asked only when the rule needs it: someone other than the author pushed to a fork head. @@ -191,7 +192,7 @@ private async Task LookUpStandingAsync(Guid reposi if (repository.Credential == null) { - _standingFailures[userExternalId] = new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "the repository has no credential bound to ask the provider with"); + _standingFailures[userExternalId] = new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "the repository has no credential bound to ask the provider with", Lookup(StandingLookupCauses.NoCredential)); return PullRequestAuthorAssociation.Unknown; } @@ -235,14 +236,21 @@ private StandingFailure ClassifyFailure(Exception failure, Repository repository CredentialNeedsReconnectException => new StandingFailure(WorkflowRunRequestRejectionReasons.CredentialRejected, $"its provider refused the repository's credential '{credential}' and it needs to be reconnected"), CredentialDisconnectedException => new StandingFailure(WorkflowRunRequestRejectionReasons.CredentialRejected, $"the repository's credential '{credential}' was disconnected"), ProviderApiException { StatusCode: 401 } => new StandingFailure(WorkflowRunRequestRejectionReasons.CredentialRejected, $"{provider} answered HTTP 401 to the repository's credential '{credential}'"), - ProviderApiException { StatusCode: 403 } => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} refused the lookup (HTTP 403): the repository's credential '{credential}' needs the read_api scope and a role on the project to read its members"), - ProviderInsufficientScopeException scope => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} refused the lookup: the repository's credential '{credential}' lacks the {string.Join(", ", scope.MissingScopes)} scope"), - ProviderApiException api => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} answered HTTP {api.StatusCode} to the lookup"), - OperationCanceledException => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} did not answer within {AuthorLookupTimeout.TotalSeconds:0} seconds"), - _ => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"the lookup failed ({failure.GetType().Name})") + ProviderApiException { StatusCode: 403 } => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} refused the lookup (HTTP 403): the repository's credential '{credential}' needs the read_api scope and a role on the project to read its members", Lookup(StandingLookupCauses.AccessRefused, 403)), + ProviderInsufficientScopeException scope => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} refused the lookup: the repository's credential '{credential}' lacks the {string.Join(", ", scope.MissingScopes)} scope", Lookup(StandingLookupCauses.AccessRefused)), + ProviderApiException api => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} answered HTTP {api.StatusCode} to the lookup", Lookup(StandingLookupCauses.NoAnswer, api.StatusCode)), + OperationCanceledException => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"{provider} did not answer within {AuthorLookupTimeout.TotalSeconds:0} seconds", Lookup(StandingLookupCauses.NoAnswer)), + _ => new StandingFailure(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, $"the lookup failed ({failure.GetType().Name})", Lookup(StandingLookupCauses.NoAnswer)) }; } + /// The machine-readable cause kept on the refusal row: which of , and the status the provider answered when it did. + private static PersistedFailure Lookup(string cause, int? providerStatus = null) => new() + { + Code = WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, + Details = providerStatus is { } status ? new Dictionary { [StandingLookupCauses.DetailKey] = cause, ["providerStatus"] = status.ToString(System.Globalization.CultureInfo.InvariantCulture) } : new Dictionary { [StandingLookupCauses.DetailKey] = cause } + }; + /// Why a lookup failed: the value it is refused under, and the cause in words. - private sealed record StandingFailure(string Reason, string Cause); + private sealed record StandingFailure(string Reason, string Cause, PersistedFailure? Lookup = null); } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IIngestionAuditor.cs b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IIngestionAuditor.cs index e270bd2ef..c4016d9ba 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IIngestionAuditor.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IIngestionAuditor.cs @@ -62,6 +62,9 @@ public sealed record WebhookRejectionContext /// public required string SourceType { get; init; } + /// The refusal's machine-readable cause ({"failure": {...}}, see PersistedFailureJson), kept on the row's request metadata beside the sentence. Null — what every refusal but one that could not read an author's standing writes — leaves the row's metadata as it always was. + public string? RequestMetadataJson { get; init; } + /// Delivery id from the provider's header (X-GitHub-Delivery, etc). Null if we couldn't extract it before failing. public string? ExternalEventId { get; init; } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IngestionAuditor.cs b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IngestionAuditor.cs index 46f4094fe..c19b8240b 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IngestionAuditor.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/IngestionAuditor.cs @@ -52,7 +52,7 @@ public async Task WriteWebhookRejectedAsync(WebhookRejectionContext context, Can ActorType = WorkflowRunActorTypes.Webhook, ActorId = null, NormalizedPayloadJson = "{}", - RequestMetadataJson = "{}", + RequestMetadataJson = context.RequestMetadataJson ?? "{}", RawHeadersRedactedJson = context.RawHeadersRedactedJson, VerificationResultJson = context.VerificationResultJson, Status = WorkflowRunRequestStatus.Rejected, diff --git a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceDispatcher.cs b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceDispatcher.cs index 52837fa38..81674103f 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceDispatcher.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/RunSources/RunSourceDispatcher.cs @@ -2,6 +2,7 @@ using CodeSpace.Core.DependencyInjection; using CodeSpace.Core.Services.Agents.Authority.Exceptions; using CodeSpace.Core.Services.Completion.Exceptions; +using CodeSpace.Core.Services.Workflows.Failures; using CodeSpace.Core.Services.Workflows.RunSources.Admission; using CodeSpace.Core.Services.Workflows.RunSources.Admission.Exceptions; using CodeSpace.Core.Middlewares.Transactional; @@ -11,6 +12,7 @@ using CodeSpace.Messages.Events; using CodeSpace.Messages.Events.PullRequest; using CodeSpace.Messages.Events.Push; +using CodeSpace.Messages.Failures; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -181,7 +183,7 @@ await _db.Repository.AsNoTracking() { anyRefused = true; _logger.LogWarning("Webhook activation could not read a pull request author's standing. ActivationId={ActivationId} WorkflowId={WorkflowId} TeamId={TeamId} Reason={Reason} Detail={Detail}", activation.Id, activation.WorkflowId, activation.Workflow.TeamId, ex.Reason, ex.Message); - await AuditRefusalAsync(activation, normalizedEvent, ex.Reason, ex.Message, ex.AuditKey, cancellationToken).ConfigureAwait(false); + await AuditRefusalAsync(activation, normalizedEvent, ex.Reason, ex.Message, ex.AuditKey, cancellationToken, ex.Lookup).ConfigureAwait(false); } catch (PullRequestTriggerDebouncedException ex) { @@ -195,13 +197,14 @@ await _db.Repository.AsNoTracking() } /// One Rejected row per , so a provider's redelivery of the same refused event collapses onto it. - private async Task AuditRefusalAsync(WorkflowActivation activation, NormalizedEvent normalizedEvent, string reason, string detail, string dedupKey, CancellationToken cancellationToken) => + private async Task AuditRefusalAsync(WorkflowActivation activation, NormalizedEvent normalizedEvent, string reason, string detail, string dedupKey, CancellationToken cancellationToken, PersistedFailure? failure = null) => await _auditor.WriteWebhookRejectedAsync(new WebhookRejectionContext { TeamId = activation.Workflow.TeamId, RepositoryId = normalizedEvent.RepositoryId, SourceType = activation.TypeKey, ExternalEventId = normalizedEvent.ProviderEventId, Reason = reason, Detail = $"Activation {activation.Id} workflow {activation.WorkflowId}: {detail}", DedupKey = dedupKey, + RequestMetadataJson = failure is null ? null : PersistedFailureJson.AsPayload(failure), }, cancellationToken).ConfigureAwait(false); /// One row per (activation, delivery, refusal kind) — a refusal that is the activation's own property and rare. diff --git a/backend/src/CodeSpace.Messages/Agents/AgentRunResult.cs b/backend/src/CodeSpace.Messages/Agents/AgentRunResult.cs index dac81e0ae..1e0be6720 100644 --- a/backend/src/CodeSpace.Messages/Agents/AgentRunResult.cs +++ b/backend/src/CodeSpace.Messages/Agents/AgentRunResult.cs @@ -283,6 +283,10 @@ public sealed record AgentRunResult [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? PublishFailureCode { get; init; } + /// The machine-readable form of the provider failure this run hit — the one that ended it, or the one that kept its branch from being published — when it is one a person can repair outside the run (a connection disconnected, refused or redirecting): its code and what it names, so the run page can say what to do instead of printing or . Null for every other outcome and omitted from the stored result then, so every earlier result reads and writes byte-identically. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Failures.PersistedFailure? Failure { get; init; } + /// Set ONLY when the publish guard chain (IPublishGuard) blocked the push — a BY-CHOICE skip (never both this and at once). The winning guard's human-readable reason, folded onto the publish-manifest row's Summary so a by-choice skip explains itself. public string? PublishSkipReason { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Constants/StandingLookupCauses.cs b/backend/src/CodeSpace.Messages/Constants/StandingLookupCauses.cs new file mode 100644 index 000000000..100e8e839 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Constants/StandingLookupCauses.cs @@ -0,0 +1,23 @@ +namespace CodeSpace.Messages.Constants; + +/// +/// Why a trigger could not learn who wrote a pull request — the structured half of an author_standing_unknown refusal. The +/// refusal row's text says it in words; these say it in a form the repository's remedy can branch on, because a lookup that failed +/// for the credential (it lacks the scope or the role) and one that failed for the provider (a timeout, a 429, a 5xx) read the same +/// in a sentence and must not send the same people to the same buttons. Written beside the refusal under , +/// next to the status the provider answered, and pinned by test: a rename turns every stored row into "cause unknown". +/// +public static class StandingLookupCauses +{ + /// The failure-details key the cause is stored under. + public const string DetailKey = "lookup"; + + /// The repository has no credential bound to ask the provider with. + public const string NoCredential = "no_credential"; + + /// The provider answered that the credential may not read the project's members (HTTP 403, or a missing scope) — the credential's to fix. + public const string AccessRefused = "access_refused"; + + /// The provider did not answer in time, answered with an error (429, 5xx) or the lookup failed some other way — nothing says the credential is wrong, and the next event asks again. + public const string NoAnswer = "no_answer"; +} diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs index b3b7b0cd4..a34efb9b0 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialSummary.cs @@ -34,4 +34,7 @@ public sealed record CredentialSummary /// Whole days until a static token expires at its provider; null for an OAuth credential (its access token renews itself) and for a token with no recorded expiry. public int? DaysToExpiry { get; init; } + + /// How many live repositories are bound through the credential — what a person reconnecting, replacing or disconnecting it needs to know is at stake. Zero from a server that predates it. + public int BoundRepositoryCount { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/ProviderRemedy.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/ProviderRemedy.cs new file mode 100644 index 000000000..db72839a9 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/ProviderRemedy.cs @@ -0,0 +1,133 @@ +using System.Text.Json.Serialization; +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Messages.Dtos.Credentials; + +/// +/// What is wrong with a provider connection and what the CALLER can do about it — the answer a run page, a repository row and a +/// refused webhook delivery all give instead of a sentence nobody can act on. Written in the caller's terms: the same failure +/// reads one way to a team Owner (every button works), another to a Member (the credential's owner's own buttons work, the rest +/// name who to ask) and another to a Viewer (nothing works; here are the people who can). The words are the backend's; a client +/// renders them and offers the actions it is told are enabled. +/// +/// Computed at read time from the failure that was written down and from what is true NOW — a credential reconnected since +/// the run failed reads , so the page offers the re-run instead of the repair. It is therefore never cached +/// with the page that shows it: the room's cached turn carries only the failure's code, and the card asks for this. +/// +public sealed record ProviderRemedy +{ + /// The failure's stable code (credential_disconnected, provider_base_url_redirects, …) — what a client keys its icon on, never the words. + public required string Code { get; init; } + + /// One line naming what is wrong, in plain language. + public required string Title { get; init; } + + /// One or two sentences of cause that name the credential and the repository — the specifics the title leaves out. + public required string Cause { get; init; } + + /// True when what the failure names works again — the credential is live, the repository is bound to a live one, the address no longer redirects — so the repair is done and the run only needs another go. + public required bool Resolved { get; init; } + + /// The credential the failure is about; null when the failure names none and none could be traced (a provider's 401 on a step with no repository). + public RemedyCredential? Credential { get; init; } + + /// The repositories the failing step acted on, most affected first, capped; empty for a failure that is about no repository. + public IReadOnlyList Repositories { get; init; } = Array.Empty(); + + /// The provider connection whose address redirects, or the instance of the credential; null when none could be named. + public RemedyProviderInstance? ProviderInstance { get; init; } + + /// For provider_base_url_redirects: the origin the provider answered with, what its base URL would be edited to. Null for every other failure. + public string? RedirectsTo { get; init; } + + /// What can be done, best first, each saying whether the caller may do it and, when not, why. Never empty. + public required IReadOnlyList Actions { get; init; } + + /// Who to ask when some action is not the caller's: the team's Owners and Admins, and the credential's own person for a token replace only they can make. Empty when every action is the caller's, or nobody can be named. + public IReadOnlyList AskPeople { get; init; } = Array.Empty(); +} + +/// The kinds of fix the remedy offers. Serialized as the lower-case names a client branches on, so a rename is a breaking change. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RemedyActionKind +{ + /// Move the repository (or every repository the credential holds) onto a live credential. + [JsonStringEnumMemberName("relink")] Relink, + + /// Sign in again with the provider, into the SAME credential, so nothing bound through it changes. Needs credentials.manage whoever owns the credential: starting an OAuth connect is that request. + [JsonStringEnumMemberName("reconnect")] Reconnect, + + /// Paste a new token into the SAME credential, so nothing bound through it changes. The credential's own person, or credentials.manage. + [JsonStringEnumMemberName("replace_token")] ReplaceToken, + + /// Correct the provider connection's address to the one the host answers on. + [JsonStringEnumMemberName("edit_provider_url")] EditProviderUrl, + + /// Open the Providers dialog — the one place every connection can be seen and repaired. + [JsonStringEnumMemberName("open_providers")] OpenProviders, + + /// Run it again; offered once what failed is . + [JsonStringEnumMemberName("rerun")] Rerun +} + +public sealed record RemedyAction +{ + public required RemedyActionKind Kind { get; init; } + public required string Label { get; init; } + + /// False when the caller may not do this; says what they would need. The button is shown disabled with the reason, never hidden: a person who cannot fix it must still learn that it is fixable and by whom. + public required bool Enabled { get; init; } + + public string? DisabledReason { get; init; } +} + +/// The credential a remedy is about — enough to name it, to say whose it is, and to tell what a repair would be. +public sealed record RemedyCredential +{ + public required Guid Id { get; init; } + public required string DisplayName { get; init; } + public required CredentialOwnership Ownership { get; init; } + public Guid? OwnerUserId { get; init; } + public string? OwnerName { get; init; } + public required AuthType AuthType { get; init; } + public required CredentialStatus Status { get; init; } + + /// What to read off it now: working, expiring, refused, disconnected, or lent by someone who left. + public required CredentialHealth Health { get; init; } + + /// Soft-deleted: its person left the team or its connection was removed. It cannot be reconnected; what leans on it can only be moved. + public required bool Retired { get; init; } + + public required Guid ProviderInstanceId { get; init; } +} + +/// A repository the failing step acted on, and whether what it runs through works now. +public sealed record RemedyRepository +{ + public required Guid Id { get; init; } + public required string FullPath { get; init; } + public required RepositoryStatus Status { get; init; } + + /// The credential the repository runs through now; null when it has none, or the one it had is gone. + public Guid? CredentialId { get; init; } + + /// The repository authenticates through a credential that works: bound, live, not refused, not retired. + public required bool CredentialWorks { get; init; } +} + +public sealed record RemedyProviderInstance +{ + public required Guid Id { get; init; } + public required string DisplayName { get; init; } + public required ProviderKind Provider { get; init; } + public required string BaseUrl { get; init; } +} + +public sealed record RemedyPerson +{ + public required Guid UserId { get; init; } + public required string Name { get; init; } + + /// Why this person: Owner or Admin of the team, or Owner of the credential. + public required string Role { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Dtos/Repositories/RepositoryRejectedDeliveries.cs b/backend/src/CodeSpace.Messages/Dtos/Repositories/RepositoryRejectedDeliveries.cs index 391c21283..13a660673 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Repositories/RepositoryRejectedDeliveries.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Repositories/RepositoryRejectedDeliveries.cs @@ -16,4 +16,11 @@ public sealed record RepositoryRejectedDeliveries /// The most rows this answer will ever carry. A full list cannot be told apart from exactly-the-cap — the read asks for the cap and gets no count beyond it, so the UI says the list stops there rather than claiming more exist. public required int Cap { get; init; } + + /// + /// What to do about the refusals that blame the repository's credential (credential_rejected, author_standing_unknown), + /// for the asking user. Present only when the list holds one of those; the same remedy serves every such row, because it + /// is about the credential the repository runs through NOW and not about any one delivery. + /// + public Credentials.ProviderRemedy? Remedy { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomBlock.cs b/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomBlock.cs index c1093b05d..94d3b8064 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomBlock.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Sessions/Room/RoomBlock.cs @@ -511,6 +511,14 @@ public sealed record DiagnosticBlock : RoomBlock /// The raw engine error, hidden behind "Show raw error". Null when there's nothing rawer than . public string? RawDetail { get; init; } + + /// + /// The code of the failure the run recorded, when it is one a person can repair outside the run (a provider connection that was + /// disconnected, refused or redirects). Static, so it is safe in the cached turn: the card it asks for — what to do and who may — + /// is read live from the run's remedy, which depends on who is looking and on what has been fixed since. Null for every other + /// failure, which keeps its title, text and actions as they were. + /// + public string? FailureCode { get; init; } } /// diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowRunDetail.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowRunDetail.cs index 90860fe80..8655c90de 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowRunDetail.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowRunDetail.cs @@ -65,6 +65,15 @@ public sealed record WorkflowRunDetail /// Timer). null unless the run is parked on a pending wait. /// public WorkflowRunWaitInfo? PendingWait { get; init; } + + /// + /// What is wrong with the provider connection this run failed on, and what the CALLER can do about it — present only for a run + /// whose recorded failure is one a person can repair (a credential disconnected or refused, a provider that redirects, a push + /// the remote refused). Computed for the asking user at read time, so the same run reads differently to an Owner, a Member and + /// a Viewer and reads "resolved" once the connection works again. Null for every other run, and for a run recorded before + /// failures were written down, which keeps reading as alone. + /// + public Dtos.Credentials.ProviderRemedy? Remedy { get; init; } } /// The pending wait a Suspended run is parked on. Drives the run-detail resume affordance. diff --git a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs index bcb45adb7..05f6dd64d 100644 --- a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs +++ b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs @@ -190,6 +190,23 @@ public static class FailureCodes /// The worker holding this run's brokered model-credential lease went away, so the detached agent's model access ended with it and its attempt can make no further model call. Remedy: a retry, which starts a fresh attempt on a live worker — nothing about the run's own inputs is wrong, and nothing on the provider's side is down. public const string ModelCredentialLeaseLost = "model_credential_lease_lost"; + /// + /// The failures that say a provider CONNECTION needs a person's attention — a credential disconnected, refused by its + /// provider, or an address that redirects — and that a person can repair without anyone touching the run: reconnect, replace + /// the token, re-link the repository, correct the provider's address. These are the ones a run's record writes down as a + /// and the run detail answers with a remedy; every other code stays the sentence it was. + /// Pinned member-by-member by a unit test: a member added here is a new kind of card on the run page. + /// + public static readonly IReadOnlySet ProviderConnection = new HashSet(StringComparer.Ordinal) + { + CredentialDisconnected, + CredentialNeedsReconnect, + ProviderUnauthorized, + GitCredentialRejected, + ProviderBaseUrlRedirects, + OAuthExchangeFailed, + }; + /// /// The codes that, worn as an agent attempt's AgentRunResult.ExitReason, say the attempt died on OUR /// INFRASTRUCTURE — the worker went away, the broker could not bind, the host could not seal a network-off run to diff --git a/backend/src/CodeSpace.Messages/Failures/PersistedFailure.cs b/backend/src/CodeSpace.Messages/Failures/PersistedFailure.cs new file mode 100644 index 000000000..722b7c34a --- /dev/null +++ b/backend/src/CodeSpace.Messages/Failures/PersistedFailure.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace CodeSpace.Messages.Failures; + +/// +/// A failure written down as what it is — a and the things it names — instead of only the sentence it +/// was said in. A run's error is free text, and a person reading "The credential 'GitLab · nacho' was disconnected" is told +/// what happened but not given anything to act on: no screen can link a sentence to a fix. This is the machine-readable +/// half, carried on the run's ledger records and on an agent run's result, so what reads them (the run detail, the Room) can +/// say what is wrong, who can fix it and with which button, and re-read the live state of the thing it names. +/// +/// Only failures declared in are written down — those with a fix a +/// person can make outside the run. Everything here is an identifier, an address or a status the failure's own +/// already puts on an API response; no token, no message text and no provider body is ever +/// carried, so the record is as safe to keep as the response was to send. +/// +public sealed record PersistedFailure +{ + /// The stable name from . + public required string Code { get; init; } + + /// What the failure's own said, as text — a credential's id and name, the address a provider redirected to, the status it answered. Absent when it said nothing. + public IReadOnlyDictionary? Details { get; init; } + + /// The repository the failing step was acting on, when the step names one — the subject a remedy re-links. A failure that carries a credential of its own does not need it, and one that carries none (a provider's 401) cannot be traced to a credential without it. + public Guid? RepositoryId { get; init; } + + public string? Detail(string key) => Details is not null && Details.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value : null; + + public Guid? DetailGuid(string key) => Guid.TryParse(Detail(key), out var id) && id != Guid.Empty ? id : null; + + /// The details as text: a value that was null is left out, anything else is written the way an invariant-culture serializer would, so what is read back is what was thrown. + public static IReadOnlyDictionary? DetailsAsText(IReadOnlyDictionary? details) + { + if (details is null) return null; + + var text = details.Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => Convert.ToString(pair.Value, CultureInfo.InvariantCulture) ?? "", StringComparer.Ordinal); + + return text.Count == 0 ? null : text; + } +} diff --git a/backend/src/CodeSpace.Messages/Failures/RecordedFailure.cs b/backend/src/CodeSpace.Messages/Failures/RecordedFailure.cs new file mode 100644 index 000000000..95548bbbe --- /dev/null +++ b/backend/src/CodeSpace.Messages/Failures/RecordedFailure.cs @@ -0,0 +1,18 @@ +namespace CodeSpace.Messages.Failures; + +/// +/// The provider failure a run recorded, with when it was recorded and where the run was working — what a remedy is computed +/// from. lets a reader tell a credential that was fixed AFTER the failure from one that merely looks fine; +/// lets a failure that names no repository (a provider's 401) be traced to the repositories the +/// run was launched against. +/// +public sealed record RecordedFailure +{ + public required PersistedFailure Failure { get; init; } + + /// When the record naming the failure was written — the node's failing, or the agent run's ending. + public required DateTimeOffset At { get; init; } + + /// The repositories the run was launched against; empty for a run launched with none (an authored workflow). + public IReadOnlyList ScopeRepositoryIds { get; init; } = Array.Empty(); +} diff --git a/backend/src/CodeSpace.Messages/Queries/Workflows/GetWorkflowRunRemedyQuery.cs b/backend/src/CodeSpace.Messages/Queries/Workflows/GetWorkflowRunRemedyQuery.cs new file mode 100644 index 000000000..ec9293607 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Workflows/GetWorkflowRunRemedyQuery.cs @@ -0,0 +1,17 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Queries.Workflows; + +/// +/// What is wrong with the provider connection a run failed on, and what the asking user can do about it — the lean read the run +/// page makes for a failed turn, so it does not pull a whole run detail (every node's inputs, outputs and the definition) to +/// learn which button to show. Null when the run recorded no failure a person can repair. Team membership alone, like the run +/// detail it mirrors: the answer names the credential and the people who can fix it, which the team's Providers list already +/// shows every member. +/// +public sealed record GetWorkflowRunRemedyQuery : IQuery, IRequireTeamMembership +{ + public required Guid RunId { get; init; } +} diff --git a/backend/tests/CodeSpace.E2ETests/Credentials/ProviderRemedyHttpE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Credentials/ProviderRemedyHttpE2ETests.cs new file mode 100644 index 000000000..44e06fba0 --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Credentials/ProviderRemedyHttpE2ETests.cs @@ -0,0 +1,301 @@ +using System.Net; +using System.Text.Json; +using CodeSpace.Core.Failures; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows.Lifecycle; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Credentials; + +/// +/// 🟢 High fidelity (HTTP surface) — what the run detail, the lean remedy read, the credential list and a repository's refused +/// deliveries answer over the wire to an Owner, a Member and a Viewer of one team. The real ASP.NET pipeline (JWT, the +/// X-Team-Id scope, the authorization behaviors, the controllers and their JSON options) over real Postgres. The failures +/// are written into the ledger by the real from the real exceptions through the real capture, so the +/// wire shape is the one the engine's own records produce; the engine's capture itself is pinned at the integration tier +/// (ProviderFailureThroughTheEngineFlowTests, with a real GitLab stub), which an HTTP test would only repeat slowly. +/// +/// Also pinned here: a button is enabled exactly when the endpoint behind it accepts the request (the credential's own Member is +/// offered no Reconnect, and the OAuth init answers them 403), a standing lookup the provider did not answer blames nobody while a row +/// written before the cause was kept keeps the repair, a run recorded before failures were kept answers exactly as it did (no +/// remedy, the same error), and another team's run answers nothing. +/// +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +public sealed class ProviderRemedyHttpE2ETests : IClassFixture +{ + private readonly TaskLaunchApiFactory _factory; + private readonly TenantHarness _tenants; + + public ProviderRemedyHttpE2ETests(TaskLaunchApiFactory factory) + { + _factory = factory; + _tenants = new TenantHarness(factory); + } + + [Fact] + public async Task The_run_detail_carries_a_remedy_computed_for_whoever_asks_an_owner_a_member_and_a_viewer() + { + var world = await SeedWorldAsync(); + var runId = await SeedFailedRunAsync(world, new CredentialNeedsReconnectException(world.CredentialId, "GitLab · nacho", "invalid_grant"), recordFailure: true); + + foreach (var (asker, label, reconnect, relink) in new[] { (world.Owner, "owner", true, true), (world.Member, "member", false, false), (world.Viewer, "viewer", false, false) }) + { + var response = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}", asker, world.Team); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, $"{label}: {body}"); + + var run = JsonDocument.Parse(body).RootElement; + run.GetProperty("error").GetString().ShouldBe("Node 'list' failed.", $"{label}: the sentence is untouched"); + + var remedy = run.GetProperty("remedy"); + remedy.GetProperty("code").GetString().ShouldBe("credential_needs_reconnect", label); + remedy.GetProperty("resolved").GetBoolean().ShouldBeFalse(label); + remedy.GetProperty("title").GetString().ShouldBe("The provider no longer accepts the credential this run used".Replace("The provider", "GitLab"), label); + remedy.GetProperty("credential").GetProperty("displayName").GetString().ShouldBe("GitLab · nacho", label); + remedy.GetProperty("credential").GetProperty("health").GetString().ShouldBe("rejected", $"{label}: enum names are the lower-case ones a client branches on"); + remedy.GetProperty("repositories")[0].GetProperty("fullPath").GetString().ShouldBe("acme/api", label); + + var actions = remedy.GetProperty("actions").EnumerateArray().ToDictionary(a => a.GetProperty("kind").GetString()!, a => a); + actions["reconnect"].GetProperty("enabled").GetBoolean().ShouldBe(reconnect, $"{label} reconnect"); + actions["relink"].GetProperty("enabled").GetBoolean().ShouldBe(relink, $"{label} relink"); + actions["open_providers"].GetProperty("enabled").GetBoolean().ShouldBeTrue(label); + actions["rerun"].GetProperty("enabled").GetBoolean().ShouldBeFalse($"{label}: nothing is fixed yet"); + + if (!reconnect) + { + actions["relink"].GetProperty("disabledReason").GetString().ShouldBe("Needs the Admin or Owner role.", label); + remedy.GetProperty("askPeople").EnumerateArray().Select(p => p.GetProperty("role").GetString()).ShouldContain("Owner", $"{label}: told who can"); + } + else + { + remedy.GetProperty("askPeople").GetArrayLength().ShouldBe(0, $"{label}: nobody to ask"); + } + } + } + + [Fact] + public async Task A_button_is_enabled_exactly_when_the_endpoint_behind_it_accepts_the_request_for_the_credentials_own_member() + { + var world = await SeedWorldAsync(); + var runId = await SeedFailedRunAsync(world, new CredentialNeedsReconnectException(world.CredentialId, "GitLab · nacho", "invalid_grant"), recordFailure: true); + + var detail = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}", world.CredentialOwner, world.Team); + var actions = JsonDocument.Parse(await detail.Content.ReadAsStringAsync()).RootElement.GetProperty("remedy").GetProperty("actions").EnumerateArray().ToDictionary(a => a.GetProperty("kind").GetString()!, a => a.GetProperty("enabled").GetBoolean()); + + var init = new { providerInstanceId = world.InstanceId, displayName = "GitLab · nacho", replaceCredentialId = world.CredentialId }; + var asTheCredentialsPerson = await _tenants.SendAsync(HttpMethod.Post, "/api/credentials/oauth/init", world.CredentialOwner, world.Team, init); + var asTheOwner = await _tenants.SendAsync(HttpMethod.Post, "/api/credentials/oauth/init", world.Owner, world.Team, init); + + actions["reconnect"].ShouldBeFalse("the card must not offer the credential's own Member a button …"); + asTheCredentialsPerson.StatusCode.ShouldBe(HttpStatusCode.Forbidden, "… that this endpoint answers 403: it needs credentials.manage, which owning the row does not give"); + asTheOwner.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden, "the team Owner passes authorization (the stub instance has no OAuth client, which is a different refusal)"); + } + + [Fact] + public async Task The_lean_remedy_read_answers_the_same_remedy_without_the_run_detail_around_it() + { + var world = await SeedWorldAsync(); + var runId = await SeedFailedRunAsync(world, new CredentialDisconnectedException(world.CredentialId, "GitLab · nacho"), recordFailure: true); + + var response = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}/remedy", world.Viewer, world.Team); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, body); + + var remedy = JsonDocument.Parse(body).RootElement; + remedy.GetProperty("code").GetString().ShouldBe("credential_disconnected"); + remedy.EnumerateObject().Select(p => p.Name).ShouldNotContain("nodes", "this is the remedy alone, not a run detail"); + remedy.GetProperty("actions").EnumerateArray().Where(a => a.GetProperty("enabled").GetBoolean()).Select(a => a.GetProperty("kind").GetString()).ShouldBe(new[] { "open_providers" }, "a Viewer may look at the connections and nothing else"); + } + + [Fact] + public async Task A_run_recorded_before_failures_were_kept_answers_exactly_as_it_did() + { + var world = await SeedWorldAsync(); + var runId = await SeedFailedRunAsync(world, new CredentialDisconnectedException(world.CredentialId, "GitLab · nacho"), recordFailure: false); + + var detail = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}", world.Owner, world.Team); + var run = JsonDocument.Parse(await detail.Content.ReadAsStringAsync()).RootElement; + + detail.StatusCode.ShouldBe(HttpStatusCode.OK); + run.GetProperty("error").GetString().ShouldBe("Node 'list' failed."); + (!run.TryGetProperty("remedy", out var remedy) || remedy.ValueKind == JsonValueKind.Null).ShouldBeTrue("nothing recorded the code, so nothing claims a remedy"); + run.GetProperty("nodes")[0].GetProperty("error").GetString().ShouldContain("was disconnected", customMessage: "the node still carries the sentence it always did"); + + var lean = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}/remedy", world.Owner, world.Team); + + lean.StatusCode.ShouldBe(HttpStatusCode.NoContent, "no remedy is an empty answer, not an error"); + } + + [Fact] + public async Task Another_teams_run_answers_nothing_to_a_stranger() + { + var world = await SeedWorldAsync(); + var runId = await SeedFailedRunAsync(world, new CredentialDisconnectedException(world.CredentialId, "GitLab · nacho"), recordFailure: true); + var stranger = await _tenants.AccountAsync(); + var otherTeam = await _tenants.TeamAsync((stranger, TeamRole.Owner)); + + var lean = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}/remedy", stranger, otherTeam); + var detail = await _tenants.SendAsync(HttpMethod.Get, $"/api/workflows/runs/{runId}", stranger, otherTeam); + + lean.StatusCode.ShouldBe(HttpStatusCode.NoContent); + detail.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task The_credential_list_returns_a_disconnected_credential_with_the_repositories_still_bound_to_it() + { + var world = await SeedWorldAsync(); + await _tenants.DbAsync(async db => + { + await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE credential SET status = 'Revoked' WHERE id = {world.CredentialId}"); + return 0; + }); + + var response = await _tenants.SendAsync(HttpMethod.Get, "/api/credentials", world.Member, world.Team); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, body); + + var credential = JsonDocument.Parse(body).RootElement.EnumerateArray().Single(c => c.GetProperty("id").GetGuid() == world.CredentialId); + credential.GetProperty("status").GetString().ShouldBe("Revoked"); + credential.GetProperty("health").GetString().ShouldBe("revoked"); + credential.GetProperty("boundRepositoryCount").GetInt32().ShouldBe(1); + } + + [Fact] + public async Task A_repositorys_refused_deliveries_carry_the_remedy_for_the_ones_that_blame_its_credential() + { + var world = await SeedWorldAsync(); + await _tenants.DbAsync(async db => + { + db.WorkflowRunRequest.Add(new WorkflowRunRequest + { + Id = Guid.NewGuid(), TeamId = world.Team, RepositoryId = world.RepositoryId, SourceType = $"{WorkflowRunSourceTypes.ProviderPrefix}gitlab", ExternalEventId = Guid.NewGuid().ToString("N"), ActorType = WorkflowRunActorTypes.Webhook, + Status = WorkflowRunRequestStatus.Rejected, Error = $"{WorkflowRunRequestRejectionReasons.CredentialRejected}: GitLab answered HTTP 401 to the repository's credential 'GitLab · nacho'", ReceivedAt = DateTimeOffset.UtcNow.AddMinutes(-2), + }); + await db.SaveChangesAsync(); + return 0; + }); + + var response = await _tenants.SendAsync(HttpMethod.Get, $"/api/repositories/{world.RepositoryId}/rejected-deliveries", world.Member, world.Team); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, body); + + var list = JsonDocument.Parse(body).RootElement; + list.GetProperty("deliveries")[0].GetProperty("reason").GetString().ShouldBe("credential_rejected"); + + var remedy = list.GetProperty("remedy"); + remedy.GetProperty("code").GetString().ShouldBe("credential_rejected"); + remedy.GetProperty("actions").EnumerateArray().Select(a => a.GetProperty("kind").GetString()).ShouldNotContain("rerun", "a refused delivery started no run"); + } + + [Fact] + public async Task A_standing_lookup_the_provider_did_not_answer_carries_a_remedy_that_blames_nobody_and_a_row_without_the_cause_still_offers_the_repair() + { + var world = await SeedWorldAsync(); + var lookup = CodeSpace.Core.Services.Workflows.Failures.PersistedFailureJson.AsPayload(new CodeSpace.Messages.Failures.PersistedFailure { Code = WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, Details = new Dictionary { [StandingLookupCauses.DetailKey] = StandingLookupCauses.NoAnswer, ["providerStatus"] = "503" } }); + await SeedStandingRefusalAsync(world, "GitLab answered HTTP 503 to the lookup", lookup, minutesAgo: 1); + + var answered = await DeliveriesRemedyAsync(world); + + answered.GetProperty("code").GetString().ShouldBe("author_standing_unknown"); + answered.GetProperty("cause").GetString().ShouldContain("not a verdict on"); + answered.GetProperty("actions").EnumerateArray().Select(a => a.GetProperty("kind").GetString()).ShouldBe(new[] { "open_providers" }, "a provider that did not answer is nobody's credential to repair"); + + // The same refusal as a row written before the cause was kept: unchanged — either is possible, so the repair stays. + var older = await SeedWorldAsync(); + await SeedStandingRefusalAsync(older, "GitLab refused the lookup (HTTP 403)", metadata: null, minutesAgo: 1); + + var unknown = await DeliveriesRemedyAsync(older); + + unknown.GetProperty("cause").GetString().ShouldContain("can also be a timeout or an error"); + unknown.GetProperty("actions").EnumerateArray().Select(a => a.GetProperty("kind").GetString()).ShouldContain("reconnect"); + } + + // ─── Seeds ───────────────────────────────────────────────────────────────── + + private async Task SeedStandingRefusalAsync(World world, string detail, string? metadata, int minutesAgo) + { + await _tenants.DbAsync(async db => + { + db.WorkflowRunRequest.Add(new WorkflowRunRequest + { + Id = Guid.NewGuid(), TeamId = world.Team, RepositoryId = world.RepositoryId, SourceType = $"{WorkflowRunSourceTypes.ProviderPrefix}gitlab", ExternalEventId = Guid.NewGuid().ToString("N"), ActorType = WorkflowRunActorTypes.Webhook, + Status = WorkflowRunRequestStatus.Rejected, Error = $"{WorkflowRunRequestRejectionReasons.AuthorStandingUnknown}: {detail}", ReceivedAt = DateTimeOffset.UtcNow.AddMinutes(-minutesAgo), RequestMetadataJson = metadata ?? "{}", + }); + await db.SaveChangesAsync(); + return 0; + }); + } + + private async Task DeliveriesRemedyAsync(World world) + { + var response = await _tenants.SendAsync(HttpMethod.Get, $"/api/repositories/{world.RepositoryId}/rejected-deliveries", world.Owner, world.Team); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, body); + + return JsonDocument.Parse(body).RootElement.GetProperty("remedy").Clone(); + } + + private sealed record World(Guid Team, Guid Owner, Guid Member, Guid Viewer, Guid CredentialOwner, Guid InstanceId, Guid CredentialId, Guid RepositoryId); + + /// A team of an Owner, a Member and a Viewer; one GitLab connection owned by a fourth person; one repository running through it. + private async Task SeedWorldAsync() + { + var owner = await _tenants.AccountAsync(); + var member = await _tenants.AccountAsync(); + var viewer = await _tenants.AccountAsync(); + var nacho = await _tenants.AccountAsync(name: $"Nacho {Guid.NewGuid():N}"[..14]); + var team = await _tenants.TeamAsync((owner, TeamRole.Owner), (member, TeamRole.Member), (viewer, TeamRole.Viewer), (nacho, TeamRole.Member)); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var instance = new ProviderInstance { Id = Guid.NewGuid(), TeamId = team, Provider = ProviderKind.GitLab, DisplayName = "gitlab", BaseUrl = $"http://127.0.0.1:1/{suffix}", ApiUrl = $"http://127.0.0.1:1/{suffix}" }; + var credential = new Credential { Id = Guid.NewGuid(), TeamId = team, ProviderInstanceId = instance.Id, OwnerUserId = nacho, Ownership = CredentialOwnership.Personal, AuthType = AuthType.OAuth, DisplayName = "GitLab · nacho", EncryptedPayload = "x", Status = CredentialStatus.Error, LastError = "needs reconnect: invalid_grant" }; + var repository = new Repository { Id = Guid.NewGuid(), TeamId = team, ProviderInstanceId = instance.Id, CredentialId = credential.Id, ExternalId = Random.Shared.Next(100_000, 999_999).ToString(), NamespacePath = "acme", Name = "api", FullPath = "acme/api", DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = "http://127.0.0.1:1/acme/api", Status = RepositoryStatus.Error }; + + await _tenants.DbAsync(async db => + { + db.ProviderInstance.Add(instance); + db.Credential.Add(credential); + db.Repository.Add(repository); + await db.SaveChangesAsync(); + return 0; + }); + + return new World(team, owner, member, viewer, nacho, instance.Id, credential.Id, repository.Id); + } + + /// A failed run whose node failed on the exception, recorded by the real ledger writer — with the machine-readable failure when , as a run recorded before they were kept otherwise. + private async Task SeedFailedRunAsync(World world, Exception thrown, bool recordFailure) + { + var requestId = Guid.NewGuid(); + var runId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + + await _tenants.DbAsync(async db => + { + db.WorkflowRunRequest.Add(new WorkflowRunRequest { Id = requestId, TeamId = world.Team, WorkflowId = null, SourceType = WorkflowRunSourceTypes.Snapshot, ActorType = "user", ActorId = SystemUsers.SeederId, NormalizedPayloadJson = "{}", Status = WorkflowRunRequestStatus.Consumed, ReceivedAt = now, VerifiedAt = now, NormalizedAt = now }); + db.WorkflowRun.Add(new WorkflowRun { Id = runId, WorkflowId = null, WorkflowVersion = null, TeamId = world.Team, RunRequestId = requestId, SourceType = WorkflowRunSourceTypes.Snapshot, Status = WorkflowRunStatus.Failure, Error = "Node 'list' failed.", ScopeRepositoryIds = [], ScopeProjectIds = [], CreatedDate = now, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + await db.SaveChangesAsync(); + return 0; + }); + + using var scope = _factory.Services.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService(); + + await logger.NodeStartedAsync(runId, "list", string.Empty, new Dictionary(), new Dictionary(), CancellationToken.None); + await logger.NodeFailedAsync(runId, "list", string.Empty, thrown.Message, TimeSpan.FromMilliseconds(4), recordFailure ? ProviderFailureCapture.From(thrown, world.RepositoryId) : null, CancellationToken.None); + + return runId; + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/ProviderFailureThroughTheEngineFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/ProviderFailureThroughTheEngineFlowTests.cs new file mode 100644 index 000000000..596124d29 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/ProviderFailureThroughTheEngineFlowTests.cs @@ -0,0 +1,506 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Core.Services.Workflows.Engine; +using CodeSpace.Core.Services.Workflows.Failures; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Commands.ProviderInstances; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using CodeSpace.Messages.Failures; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Credentials; + +/// +/// 🟢 High fidelity — a provider connection that fails UNDER A RUN is written down as what it is. The real workflow engine walks a +/// real git.list_prs node, which calls the real GitLab provider through the real credential resolver, OAuth refresher and +/// advisory lock over real Postgres; the one fake is GitLab (, and a loopback host that +/// redirects). The node's failure is recorded on its node.failed ledger record as a machine-readable failure beside the +/// sentence, and the remedy computed from it answers, for the person asking, what is wrong and what they can do. +/// +/// One scenario per way a connection fails: a dead OAuth grant, a token GitLab revoked (on a list and on each git write node, which word +/// the 401 themselves), a disconnected credential, a renewal GitLab did not answer (which blames nobody), an address that redirects — whose +/// repair is proved against the real update command, not an edit production would refuse — plus the negatives that keep the record honest: a +/// failure that is not a connection records nothing, a failure the run handled with an error edge has no remedy, and neither has one the +/// run handled before it failed on something else. +/// +/// The one fake is GitLab, so the only thing between these tests and production is the provider's own answers. +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public class ProviderFailureThroughTheEngineFlowTests +{ + private readonly PostgresFixture _fixture; + + public ProviderFailureThroughTheEngineFlowTests(PostgresFixture fixture) { _fixture = fixture; } + + [Fact] + public async Task A_dead_oauth_grant_is_recorded_on_the_failed_node_and_the_remedy_says_reconnect() + { + using var gitlab = new GitLabDoorkeeperStub(); // never heard of the pair the row holds: the refresh is answered invalid_grant + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAsync(_fixture, gitlab.BaseUrl, "at-0", "rt-0", expired: true, existingTeam: team); + + var runId = await RunListPullRequestsAsync(team, seed.RepositoryId); + + var failure = await ReadRecordedFailureAsync(runId); + + failure.Code.ShouldBe(FailureCodes.CredentialNeedsReconnect); + failure.DetailGuid("credentialId").ShouldBe(seed.CredentialId, "the failure names the credential the provider refused"); + failure.RepositoryId.ShouldBe(seed.RepositoryId, "and the repository the node was acting on, read off its declared repository input"); + (await ReadRunErrorAsync(runId)).ShouldBe("Node 'list' failed.", "the sentence the run always carried is untouched"); + + var remedy = await RemedyForAsync(team.UserId, team.TeamId, runId); + + remedy.Code.ShouldBe(FailureCodes.CredentialNeedsReconnect); + remedy.Resolved.ShouldBeFalse(); + remedy.Credential!.Id.ShouldBe(seed.CredentialId); + remedy.Credential.Status.ShouldBe(CredentialStatus.Error); + remedy.Credential.Health.ShouldBe(CredentialHealth.Rejected); + remedy.Repositories.ShouldHaveSingleItem().FullPath.ShouldBe("acme/api"); + remedy.Repositories[0].CredentialWorks.ShouldBeFalse(); + remedy.Title.ShouldBe("GitLab no longer accepts the credential this run used"); + remedy.Cause.ShouldContain("“GitLab · NACHO.L”"); + remedy.Actions.Select(a => (a.Kind, a.Enabled)).ShouldBe(new[] + { + (RemedyActionKind.Reconnect, true), (RemedyActionKind.Relink, true), (RemedyActionKind.OpenProviders, true), (RemedyActionKind.Rerun, false) + }, "an Owner may do everything but run it again — that waits for the fix"); + } + + [Fact] + public async Task A_renewal_gitlab_did_not_answer_is_recorded_naming_the_answer_and_the_remedy_does_not_send_anyone_to_repair_the_credential() + { + await using var gitlab = LoopbackResponder.Start(_ => "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); // an outage: not an OAuth answer at all + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAsync(_fixture, $"http://127.0.0.1:{gitlab.Port}", "at-0", "rt-0", expired: true, existingTeam: team); + + var runId = await RunListPullRequestsAsync(team, seed.RepositoryId); + + var failure = await ReadRecordedFailureAsync(runId); + + failure.Code.ShouldBe(FailureCodes.OAuthExchangeFailed, "the provider did not answer; it did not refuse the grant (that would be credential_needs_reconnect)"); + failure.DetailGuid("credentialId").ShouldBe(seed.CredentialId); + failure.Detail("oauthError").ShouldBe("http_503"); + (await GitLabCredentialSeed.ReadCredentialAsync(_fixture, seed.CredentialId)).Status.ShouldBe(CredentialStatus.Active, "fixture check: nothing marked the credential — an outage is not a verdict on it"); + + var remedy = await RemedyForAsync(team.UserId, team.TeamId, runId); + + remedy.Title.ShouldBe("Renewing the sign-in this run used did not work"); + remedy.Cause.ShouldContain("(it answered http_503)"); + remedy.Cause.ShouldContain("says nothing about the credential"); + remedy.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }, "a healthy credential is not sent to be reconnected for somebody else's outage"); + remedy.Actions.Single(a => a.Kind == RemedyActionKind.Rerun).Enabled.ShouldBeTrue(); + } + + [Fact] + public async Task A_token_gitlab_revoked_is_recorded_as_a_rejected_credential_traced_to_its_credential_through_the_repository() + { + using var gitlab = new GitLabDoorkeeperStub(); + var token = $"glpat-{Guid.NewGuid():N}"; + gitlab.AddAccessToken(token, "nacho"); + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAccessTokenAsync(_fixture, gitlab.BaseUrl, token, existingTeam: team); + gitlab.RevokeAccessToken(token); + + var runId = await RunListPullRequestsAsync(team, seed.RepositoryId); + + var failure = await ReadRecordedFailureAsync(runId); + + failure.Code.ShouldBe(FailureCodes.ProviderUnauthorized); + failure.Detail("providerStatus").ShouldBe("401"); + failure.DetailGuid("credentialId").ShouldBeNull("GitLab's answer names no credential"); + failure.RepositoryId.ShouldBe(seed.RepositoryId); + + var remedy = await RemedyForAsync(team.UserId, team.TeamId, runId); + + remedy.Credential!.Id.ShouldBe(seed.CredentialId, "traced through the repository the node acted on"); + remedy.Credential.AuthType.ShouldBe(AuthType.Pat); + remedy.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.ReplaceToken, RemedyActionKind.Relink, RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }, "a pasted token is replaced, not reconnected"); + remedy.Actions.Single(a => a.Kind == RemedyActionKind.Rerun).Enabled.ShouldBeTrue("a 401 on a row that looks fine cannot be shown fixed, so running it again stays open"); + (await GitLabCredentialSeed.ReadCredentialAsync(_fixture, seed.CredentialId)).Status.ShouldBe(CredentialStatus.Active, "fixture check: nothing marked the credential — only the failure knows"); + } + + [Fact] + public async Task A_disconnected_credential_is_recorded_and_re_linking_the_repository_resolves_the_remedy() + { + using var gitlab = new GitLabDoorkeeperStub(); + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAsync(_fixture, gitlab.BaseUrl, "at-0", "rt-0", expired: false, existingTeam: team); + await DisconnectAsync(seed.CredentialId); + + var runId = await RunListPullRequestsAsync(team, seed.RepositoryId); + + var failure = await ReadRecordedFailureAsync(runId); + + failure.Code.ShouldBe(FailureCodes.CredentialDisconnected); + failure.DetailGuid("credentialId").ShouldBe(seed.CredentialId); + + var before = await RemedyForAsync(team.UserId, team.TeamId, runId); + before.Resolved.ShouldBeFalse(); + before.Title.ShouldBe("The credential this run used was disconnected"); + + // The repository is linked to a credential that works — the fix an Admin makes from the card. + var replacement = await AddLiveCredentialAsync(seed); + await BindAsync(seed.RepositoryId, replacement); + + var after = await RemedyForAsync(team.UserId, team.TeamId, runId); + + after.Resolved.ShouldBeTrue("what failed works again"); + after.Title.ShouldBe("This connection works again"); + after.Repositories.ShouldHaveSingleItem().CredentialWorks.ShouldBeTrue(); + after.Actions[0].Kind.ShouldBe(RemedyActionKind.Rerun, "running it again leads once the fix shows"); + after.Actions[0].Enabled.ShouldBeTrue(); + } + + [Fact] + public async Task A_provider_whose_address_redirects_offers_only_the_repair_the_server_accepts_and_that_repair_resolves_it() + { + await using var elsewhere = LoopbackResponder.Start(_ => LoopbackResponder.Ok("[]")); + var destination = $"http://localhost:{elsewhere.Port}"; + await using var redirecting = LoopbackResponder.Start(_ => LoopbackResponder.Redirect(destination + "/api/v4/x")); + var baseUrl = $"http://127.0.0.1:{redirecting.Port}"; + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAccessTokenAsync(_fixture, baseUrl, $"glpat-{Guid.NewGuid():N}", existingTeam: team); + + var runId = await RunListPullRequestsAsync(team, seed.RepositoryId); + + var failure = await ReadRecordedFailureAsync(runId); + + failure.Code.ShouldBe(FailureCodes.ProviderBaseUrlRedirects); + failure.Detail("requestedOrigin").ShouldBe(baseUrl); + failure.Detail("finalOrigin").ShouldBe(destination); + elsewhere.Accepted.ShouldBe(0, "fixture check: the redirect was not followed, so the token went nowhere else"); + + var remedy = await RemedyForAsync(team.UserId, team.TeamId, runId); + + remedy.Resolved.ShouldBeFalse(); + remedy.RedirectsTo.ShouldBe(destination); + remedy.ProviderInstance!.Id.ShouldBe(seed.InstanceId); + remedy.Title.ShouldBe("The provider's address redirects somewhere else"); + remedy.Cause.ShouldContain("1 credential is connected through it", Case.Insensitive); + remedy.Cause.ShouldContain("Disconnect them first"); + remedy.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }, "another host with a token connected: no edit button, because the server would refuse the edit it names"); + + // The refusal the old card ran into: the SAME edit through the real command, with the token still connected. + var refused = await Should.ThrowAsync(() => UpdateAddressAsync(team, seed.InstanceId, destination)); + + refused.Message.ShouldContain("1 connected credential"); + + // What the cause says to do: disconnect them first — and now the edit is offered, accepted, and resolves it. + await DisconnectThroughTheCommandAsync(team, seed.CredentialId); + + var afterDisconnect = await RemedyForAsync(team.UserId, team.TeamId, runId); + + afterDisconnect.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.EditProviderUrl, RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }, "nothing is connected through it any more, so the edit the card names is accepted"); + afterDisconnect.Actions.Single(a => a.Kind == RemedyActionKind.EditProviderUrl).Enabled.ShouldBeTrue(); + + await UpdateAddressAsync(team, seed.InstanceId, destination); + + (await RemedyForAsync(team.UserId, team.TeamId, runId)).Resolved.ShouldBeTrue("the address was edited off the one that redirected, through the same command the card's button runs"); + } + + [Theory] + // the node, its inputs, whether it declares the repository it acts on (so the failure can be traced to its credential on its own — the issue nodes declare none, and are traced through the run's repositories when it has any) + [InlineData("git.open_pr", """{"title":"t","sourceBranch":"feature","targetBranch":"main"}""", true)] + [InlineData("git.merge_pr", """{"number":7}""", true)] + [InlineData("git.pr_review", """{"number":7,"verdict":"approve"}""", true)] + [InlineData("git.create_issue", """{"title":"t"}""", false)] + [InlineData("git.close_issue", """{"number":7}""", false)] + [InlineData("git.comment_issue", """{"number":7,"body":"hello"}""", false)] + public async Task A_token_gitlab_rejects_on_a_git_write_node_is_recorded_as_a_rejected_credential_though_the_node_words_its_own_failure(string typeKey, string inputs, bool declaresItsRepository) + { + await using var gitlab = LoopbackResponder.Start(_ => "HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: 30\r\nConnection: close\r\n\r\n{\"message\":\"401 Unauthorized\"}"); + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAccessTokenAsync(_fixture, $"http://127.0.0.1:{gitlab.Port}", $"glpat-{Guid.NewGuid():N}", existingTeam: team); + var workflowId = await CreateWorkflowAsync(team, NodeThenEndDefinition(typeKey, seed.RepositoryId, inputs)); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, team.TeamId); + + await RunEngineAsync(runId); + + using var scope = _fixture.BeginScope(); + var nodeError = await scope.Resolve().WorkflowRunRecord.AsNoTracking().Where(r => r.RunId == runId && r.RecordType == WorkflowRunRecordTypes.NodeFailed).Select(r => r.PayloadJson).SingleAsync(); + + JsonDocument.Parse(nodeError).RootElement.GetProperty("error").GetString().ShouldContain("401", customMessage: "fixture check: the node worded the failure itself, from the provider's 401 — that sentence must stay as it was"); + + var failure = await ReadRecordedFailureAsync(runId); + + failure.Code.ShouldBe(FailureCodes.ProviderUnauthorized, $"{typeKey}: a rejected token is the connection's failure however the node words its own"); + failure.Detail("providerStatus").ShouldBe("401"); + + var remedy = await RemedyForAsync(team.UserId, team.TeamId, runId); + + remedy.Code.ShouldBe(FailureCodes.ProviderUnauthorized); + + if (!declaresItsRepository) return; + + failure.RepositoryId.ShouldBe(seed.RepositoryId, "traced through the repository the node declares it acts on"); + remedy.Credential!.Id.ShouldBe(seed.CredentialId); + remedy.Actions.Select(a => a.Kind).ShouldContain(RemedyActionKind.ReplaceToken); + } + + [Fact] + public async Task A_failure_that_is_not_a_provider_connection_records_no_failure_and_has_no_remedy() + { + var key = Guid.NewGuid().ToString("N"); + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var workflowId = await CreateWorkflowAsync(team, FlakyThenEndDefinition(key)); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, team.TeamId); + + await RunEngineAsync(runId); + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var payload = await db.WorkflowRunRecord.AsNoTracking().Where(r => r.RunId == runId && r.RecordType == WorkflowRunRecordTypes.NodeFailed).Select(r => r.PayloadJson).SingleAsync(); + + JsonDocument.Parse(payload).RootElement.TryGetProperty("failure", out _).ShouldBeFalse("the record is byte-identical to what it always was"); + (await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId)).Status.ShouldBe(WorkflowRunStatus.Failure); + (await scope.Resolve().ReadAsync(runId, team.TeamId, CancellationToken.None)).ShouldBeNull(); + (await RemedyForAsync(team.UserId, team.TeamId, runId, allowNone: true)).ShouldBeNull(); + } + + [Fact] + public async Task A_provider_failure_the_run_handled_with_an_error_edge_leaves_a_successful_run_with_no_remedy() + { + using var gitlab = new GitLabDoorkeeperStub(); + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAsync(_fixture, gitlab.BaseUrl, "at-0", "rt-0", expired: false, existingTeam: team); + await DisconnectAsync(seed.CredentialId); + var workflowId = await CreateWorkflowAsync(team, ListWithErrorEdgeDefinition(seed.RepositoryId)); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, team.TeamId); + + await RunEngineAsync(runId); + + using var scope = _fixture.BeginScope(); + + (await scope.Resolve().WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId)).Status.ShouldBe(WorkflowRunStatus.Success, "fixture check: the error edge handled the failure"); + var payload = await scope.Resolve().WorkflowRunRecord.AsNoTracking().Where(r => r.RunId == runId && r.RecordType == WorkflowRunRecordTypes.NodeFailed).Select(r => r.PayloadJson).SingleAsync(); + + PersistedFailureJson.Read(payload)!.Code.ShouldBe(FailureCodes.CredentialDisconnected, "the node's own record still says what happened"); + (await scope.Resolve().ReadAsync(runId, team.TeamId, CancellationToken.None)).ShouldBeNull("but the run did not fail on it"); + (await RemedyForAsync(team.UserId, team.TeamId, runId, allowNone: true)).ShouldBeNull("a run that handled it is not one anybody has to repair"); + } + + [Fact] + public async Task A_provider_failure_the_run_handled_and_then_failed_on_something_else_has_no_remedy_for_the_old_failure() + { + using var gitlab = new GitLabDoorkeeperStub(); + var key = Guid.NewGuid().ToString("N"); + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var seed = await GitLabCredentialSeed.SeedAsync(_fixture, gitlab.BaseUrl, "at-0", "rt-0", expired: false, existingTeam: team); + await DisconnectAsync(seed.CredentialId); + var workflowId = await CreateWorkflowAsync(team, ListHandledThenFlakyDefinition(seed.RepositoryId, key)); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, team.TeamId); + + await RunEngineAsync(runId); + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var run = await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId); + var failedNodes = await db.WorkflowRunRecord.AsNoTracking().Where(r => r.RunId == runId && r.RecordType == WorkflowRunRecordTypes.NodeFailed).OrderBy(r => r.Sequence).Select(r => new { r.NodeId, r.PayloadJson }).ToListAsync(); + + run.Status.ShouldBe(WorkflowRunStatus.Failure, "fixture check: the run failed"); + run.Error.ShouldBe("Node 'flaky' failed.", "fixture check: it failed on the flaky node, not the connection"); + failedNodes.Select(n => n.NodeId).ShouldBe(new[] { "list", "flaky" }); + PersistedFailureJson.Read(failedNodes[0].PayloadJson)!.Code.ShouldBe(FailureCodes.CredentialDisconnected, "fixture check: the OLD failure is on the ledger, carrying the code the page must not show"); + PersistedFailureJson.Read(failedNodes[1].PayloadJson).ShouldBeNull(); + + (await scope.Resolve().ReadAsync(runId, team.TeamId, CancellationToken.None)).ShouldBeNull("the connection failure was handled by the error edge; the run ended on the node that failed after it"); + (await RemedyForAsync(team.UserId, team.TeamId, runId, allowNone: true)).ShouldBeNull("so no card blames a credential for a run that failed for another reason"); + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + private async Task RunListPullRequestsAsync((Guid TeamId, Guid UserId) team, Guid repositoryId) + { + var workflowId = await CreateWorkflowAsync(team, ListThenEndDefinition(repositoryId)); + var runId = await WorkflowsTestSeed.SeedManualRunAsync(_fixture, workflowId, team.TeamId); + + await RunEngineAsync(runId); + + using var scope = _fixture.BeginScope(); + + (await scope.Resolve().WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId)).Status.ShouldBe(WorkflowRunStatus.Failure, "fixture check: the connection's failure failed the run"); + + return runId; + } + + private async Task ReadRecordedFailureAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + var teamId = await scope.Resolve().WorkflowRun.AsNoTracking().Where(r => r.Id == runId).Select(r => r.TeamId).SingleAsync(); + var recorded = await scope.Resolve().ReadAsync(runId, teamId, CancellationToken.None); + + recorded.ShouldNotBeNull("the node's record carries the failure"); + recorded.At.ShouldBeGreaterThan(DateTimeOffset.UtcNow.AddMinutes(-5)); + + return recorded.Failure; + } + + private async Task ReadRunErrorAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + + return await scope.Resolve().WorkflowRun.AsNoTracking().Where(r => r.Id == runId).Select(r => r.Error).SingleAsync(); + } + + /// The remedy as the given member of the team sees it — a membership-scoped request, not an instance Admin's. + private async Task RemedyForAsync(Guid userId, Guid teamId, Guid runId, bool allowNone = false) + { + using var scope = _fixture.BeginScopeAs(userId, teamId); + var remedy = await scope.Resolve().GetForRunAsync(runId, teamId, CancellationToken.None); + + if (!allowNone) remedy.ShouldNotBeNull("the run recorded a failure a person can repair"); + + return remedy; + } + + private async Task DisconnectAsync(Guid credentialId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Database.ExecuteSqlInterpolatedAsync($"UPDATE credential SET status = 'Revoked', encrypted_payload = '' WHERE id = {credentialId}"); + } + + private async Task BindAsync(Guid repositoryId, Guid credentialId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Database.ExecuteSqlInterpolatedAsync($"UPDATE repository SET credential_id = {credentialId} WHERE id = {repositoryId}"); + } + + /// Another working connection on the SAME provider instance — what a team's service token, or a person's second sign-in, is. + private async Task AddLiveCredentialAsync(SeededGitLabCredential seed) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var payload = new CodeSpace.Messages.Credentials.PatPayload { Token = $"glpat-{Guid.NewGuid():N}" }; + var credential = new Credential + { + Id = Guid.NewGuid(), TeamId = seed.TeamId, ProviderInstanceId = seed.InstanceId, Ownership = CredentialOwnership.TeamService, AuthType = AuthType.GroupAccessToken, DisplayName = "GitLab · group token", + EncryptedPayload = scope.Resolve().Encrypt(scope.Resolve().Serialize(payload)), Status = CredentialStatus.Active, + }; + + db.Credential.Add(credential); + await db.SaveChangesAsync(); + + return credential.Id; + } + + /// The edit the card's Edit provider address button opens: the real update command, as the team's Admin, with the address the provider pointed at for both the base and the API URL — and the OAuth client secret typed again, which the form asks for whenever the host changes. + private async Task UpdateAddressAsync((Guid TeamId, Guid UserId) team, Guid instanceId, string address) + { + using var scope = _fixture.BeginScopeAs(team.UserId, team.TeamId, Roles.Admin); + + await scope.Resolve().Send(new UpdateProviderInstanceCommand { ProviderInstanceId = instanceId, BaseUrl = address, ApiUrl = address, OauthClientSecret = GitLabDoorkeeperStub.DefaultClientSecret }); + } + + /// Disconnect, through the real command — what the cause says to do before the address may move. + private async Task DisconnectThroughTheCommandAsync((Guid TeamId, Guid UserId) team, Guid credentialId) + { + using var scope = _fixture.BeginScopeAs(team.UserId, team.TeamId, Roles.Admin); + + await scope.Resolve().Send(new RevokeCredentialCommand { CredentialId = credentialId }); + } + + private async Task CreateWorkflowAsync((Guid TeamId, Guid UserId) team, WorkflowDefinition definition) + { + using var scope = _fixture.BeginScopeAs(team.UserId, team.TeamId, Roles.Admin); + + return await scope.Resolve().Send(new CreateWorkflowCommand + { + Name = "provider-failure-" + Guid.NewGuid().ToString("N")[..6], Description = null, Definition = definition, + Activations = new List(), Enabled = true, + }); + } + + private async Task RunEngineAsync(Guid runId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().ExecuteRunAsync(runId, CancellationToken.None); + } + + private static NodeDefinition ListNode(Guid repositoryId) => new() { Id = "list", TypeKey = "git.list_prs", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json($$"""{"repositoryId":"{{repositoryId}}"}""") }; + + // start → → end. No error edge: the node's failure fails the run. + private static WorkflowDefinition NodeThenEndDefinition(string typeKey, Guid repositoryId, string inputs) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "write", TypeKey = typeKey, Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json(inputs.Insert(1, $"\"repositoryId\":\"{repositoryId}\",")) }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + Edges = new List { new() { From = "start", To = "write" }, new() { From = "write", To = "end" } }, + }; + + // start → list (git.list_prs) → end. No error edge: the connection's failure fails the run. + private static WorkflowDefinition ListThenEndDefinition(Guid repositoryId) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + ListNode(repositoryId), + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + Edges = new List { new() { From = "start", To = "list" }, new() { From = "list", To = "end" } }, + }; + + // start → list → end; list =(error)=> caught. The run handles the failure and succeeds. + private static WorkflowDefinition ListWithErrorEdgeDefinition(Guid repositoryId) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + ListNode(repositoryId), + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "caught", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.Json("""{"message":"{{nodes.list.outputs.error.message}}"}""") }, + }, + Edges = new List + { + new() { From = "start", To = "list" }, new() { From = "list", To = "end" }, new() { From = "list", To = "caught", SourceHandle = WorkflowHandles.Error }, + }, + }; + + // start → list =(error)=> flaky → end. list's failure is handled; flaky has no error edge, so IT fails the run. + private static WorkflowDefinition ListHandledThenFlakyDefinition(Guid repositoryId, string key) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + ListNode(repositoryId), + new() { Id = "flaky", TypeKey = FlakyTestNode.Key, Config = WorkflowsTestSeed.Json($$"""{"key":"{{key}}","failTimes":99}"""), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + Edges = new List + { + new() { From = "start", To = "list" }, new() { From = "list", To = "end" }, new() { From = "list", To = "flaky", SourceHandle = WorkflowHandles.Error }, new() { From = "flaky", To = "end" }, + }, + }; + + private static WorkflowDefinition FlakyThenEndDefinition(string key) => new() + { + SchemaVersion = 1, + Nodes = new List + { + new() { Id = "start", TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "flaky", TypeKey = FlakyTestNode.Key, Config = WorkflowsTestSeed.Json($$"""{"key":"{{key}}","failTimes":99}"""), Inputs = WorkflowsTestSeed.EmptyJson() }, + new() { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + Edges = new List { new() { From = "start", To = "flaky" }, new() { From = "flaky", To = "end" } }, + }; +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/ProviderRemedyFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/ProviderRemedyFlowTests.cs new file mode 100644 index 000000000..f65c5c3a9 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/ProviderRemedyFlowTests.cs @@ -0,0 +1,623 @@ +using Autofac; +using CodeSpace.Core.Failures; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Core.Services.Webhooks; +using CodeSpace.Core.Services.Workflows.Lifecycle; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using CodeSpace.Messages.Failures; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Credentials; + +/// +/// 🟢 High fidelity — what a remedy says and offers, for each person who might be looking, read from real rows over real Postgres: +/// the real reader of a run's recorded failure, the real membership resolver and the real permission matrix. The failures are +/// written by the real from the real exceptions, so the shapes cannot drift from what the engine +/// writes; the engine's own capture of them is . +/// +/// Pins: the per-role matrix (an Owner, an Admin, a Member, a Viewer, and the credential's own person — who may replace their own +/// pasted token but not reconnect a sign-in, because that endpoint takes credentials.manage), a retired credential, a failure traced through +/// the run's scope, an agent run's own account (only the LAST one, and only when the node that ended the run is the one that spawned it), a +/// run whose handled provider failure is not why it ended, a push the remote refused on a run that otherwise finished, a run of another +/// team, a redirect inside one origin (no address to edit), a base URL carrying userinfo (masked), and the refused-delivery remedy a +/// repository's trigger carries — told apart by whether the credential or the provider failed the lookup. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public class ProviderRemedyFlowTests +{ + private readonly PostgresFixture _fixture; + + public ProviderRemedyFlowTests(PostgresFixture fixture) { _fixture = fixture; } + + // ─── Who can press what ──────────────────────────────────────────────────── + + [Theory] + // viewer role of the asker, may reconnect, may re-link, may run it again + [InlineData(TeamRole.Owner, true, true, false)] + [InlineData(TeamRole.Admin, true, true, false)] + [InlineData(TeamRole.Member, false, false, false)] + [InlineData(TeamRole.Viewer, false, false, false)] + public async Task The_same_failure_reads_differently_to_each_role_and_the_ones_who_cannot_are_told_who_can(TeamRole role, bool reconnect, bool relink, bool rerun) + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var asker = await AddMemberAsync(world.TeamId, role, $"Asker {role}"); + var runId = await SeedFailedRunAsync(world, new CredentialNeedsReconnectException(world.CredentialId, "GitLab · nacho", "invalid_grant")); + + var remedy = (await RemedyAsync(asker, world.TeamId, runId))!; + var enabled = remedy.Actions.ToDictionary(a => a.Kind, a => a.Enabled); + + enabled[RemedyActionKind.Reconnect].ShouldBe(reconnect); + enabled[RemedyActionKind.Relink].ShouldBe(relink); + enabled[RemedyActionKind.OpenProviders].ShouldBeTrue("looking at the connections is every member's"); + enabled[RemedyActionKind.Rerun].ShouldBe(rerun, "nothing is fixed yet, so nobody may run it again"); + + if (reconnect) + { + remedy.AskPeople.ShouldBeEmpty("every fix is the caller's own, so there is no one to ask"); + return; + } + + remedy.Actions.Single(a => a.Kind == RemedyActionKind.Reconnect).DisabledReason.ShouldBe("Needs the Admin or Owner role.", "the sign-in flow is a credentials.manage request whoever the credential belongs to"); + remedy.AskPeople.Select(p => p.Role).ShouldBe(new[] { "Owner", "Admin" }, "the credential's own person cannot reconnect it either, so only the team's Owners and Admins are named"); + remedy.AskPeople.ShouldAllBe(p => p.UserId != asker, "nobody is told to ask themselves"); + } + + [Theory] + // role of the asker, may replace the token + [InlineData(TeamRole.Owner, true)] + [InlineData(TeamRole.Admin, true)] + [InlineData(TeamRole.Member, false)] + [InlineData(TeamRole.Viewer, false)] + public async Task A_pasted_token_that_is_not_the_askers_is_theirs_to_replace_only_as_an_admin_and_the_credentials_person_is_named(TeamRole role, bool replace) + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member, authType: AuthType.Pat); + var asker = await AddMemberAsync(world.TeamId, role, $"Asker {role}"); + var runId = await SeedFailedRunAsync(world, new CredentialNeedsReconnectException(world.CredentialId, "GitLab · nacho", "invalid_grant")); + + var remedy = (await RemedyAsync(asker, world.TeamId, runId))!; + var action = remedy.Actions.Single(a => a.Kind == RemedyActionKind.ReplaceToken); + + action.Enabled.ShouldBe(replace); + remedy.Actions.ShouldNotContain(a => a.Kind == RemedyActionKind.Reconnect, "a pasted token is replaced, not signed in again"); + + if (replace) return; + + action.DisabledReason.ShouldBe($"Only {world.CredentialOwnerName}, or a team Admin, can do this."); + remedy.AskPeople.Select(p => p.Role).ShouldBe(new[] { "Owner of the credential", "Owner", "Admin" }, "the person whose token it is, then the team's Owners and Admins"); + remedy.AskPeople[0].Name.ShouldBe(world.CredentialOwnerName); + } + + [Fact] + public async Task The_credentials_own_person_can_replace_their_pasted_token_as_a_plain_member_but_cannot_re_link_the_repository() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member, authType: AuthType.Pat); + var runId = await SeedFailedRunAsync(world, new CredentialNeedsReconnectException(world.CredentialId, "GitLab · nacho", "invalid_grant")); + + var remedy = (await RemedyAsync(world.CredentialOwnerId, world.TeamId, runId))!; + + remedy.Actions.Single(a => a.Kind == RemedyActionKind.ReplaceToken).Enabled.ShouldBeTrue("a person replaces their own token on membership alone"); + remedy.Actions.Single(a => a.Kind == RemedyActionKind.Relink).Enabled.ShouldBeFalse(); + remedy.Actions.Single(a => a.Kind == RemedyActionKind.Relink).DisabledReason.ShouldBe("Needs the Admin or Owner role."); + remedy.AskPeople.Select(p => p.Role).ShouldBe(new[] { "Owner", "Admin" }); + } + + [Fact] + public async Task The_credentials_own_person_is_not_offered_a_reconnect_of_their_oauth_sign_in_as_a_plain_member_because_the_endpoint_would_refuse_it() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var runId = await SeedFailedRunAsync(world, new CredentialNeedsReconnectException(world.CredentialId, "GitLab · nacho", "invalid_grant")); + + var remedy = (await RemedyAsync(world.CredentialOwnerId, world.TeamId, runId))!; + var reconnect = remedy.Actions.Single(a => a.Kind == RemedyActionKind.Reconnect); + + reconnect.Enabled.ShouldBeFalse("POST /api/credentials/oauth/init needs credentials.manage"); + reconnect.DisabledReason.ShouldBe("Needs the Admin or Owner role."); + remedy.AskPeople.Select(p => p.Role).ShouldBe(new[] { "Owner", "Admin" }); + + // The probe the button's state stands for: the same asker sending the same command is refused by the same rule. + using var scope = _fixture.BeginScopeAs(world.CredentialOwnerId, world.TeamId); + var refused = await Should.ThrowAsync(() => scope.Resolve().Send(new CodeSpace.Messages.Commands.Credentials.InitCredentialOAuthCommand { ProviderInstanceId = world.InstanceId, DisplayName = "GitLab · nacho", ReplaceCredentialId = world.CredentialId })); + + refused.Message.ShouldContain("credentials.manage"); + } + + [Fact] + public async Task A_retired_credential_names_that_it_was_retired_and_offers_only_a_re_link() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await RetireAsync(world.CredentialId); + var runId = await SeedFailedRunAsync(world, new CredentialDisconnectedException(world.CredentialId, "GitLab · nacho")); + + var remedy = (await RemedyAsync(world.OwnerId, world.TeamId, runId))!; + + remedy.Credential!.Retired.ShouldBeTrue(); + remedy.Title.ShouldBe("The credential this run used was retired"); + remedy.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.Relink, RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }); + remedy.Resolved.ShouldBeFalse(); + } + + // ─── Where the failure is read from ──────────────────────────────────────── + + [Fact] + public async Task A_redirect_inside_one_origin_offers_no_address_edit_because_there_is_no_address_to_edit_to() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var address = new Uri(await BaseUrlOfAsync(world.InstanceId)); + var runId = await SeedFailedRunAsync(world, new ProviderBaseUrlRedirectsException(ProviderKind.GitLab, address, new Uri(address, "/elsewhere"))); + + var remedy = (await RemedyAsync(world.OwnerId, world.TeamId, runId))!; + + remedy.Code.ShouldBe(FailureCodes.ProviderBaseUrlRedirects); + remedy.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }); + remedy.Cause.ShouldContain("remove the redirect on the host"); + remedy.Cause.ShouldNotContain("Change the provider's address", Case.Insensitive); + } + + [Fact] + public async Task The_instance_base_url_a_remedy_carries_is_masked_like_the_provider_list_masks_it() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await SetBaseUrlAsync(world.InstanceId, "http://deploy:glpat-s3cret@127.0.0.1:1/gitlab"); + var runId = await SeedFailedRunAsync(world, new CredentialDisconnectedException(world.CredentialId, "GitLab · nacho")); + + var viewer = await AddMemberAsync(world.TeamId, TeamRole.Viewer, "Vera Viewer"); + var remedy = (await RemedyAsync(viewer, world.TeamId, runId))!; + + remedy.ProviderInstance!.BaseUrl.ShouldBe("http://***:***@127.0.0.1:1/gitlab", "a Viewer reads the run, and a base URL may carry userinfo the list never shows"); + System.Text.Json.JsonSerializer.Serialize(remedy).ShouldNotContain("s3cret"); + } + + [Fact] + public async Task A_provider_401_with_no_repository_recorded_is_traced_through_the_repositories_the_run_was_launched_against() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var runId = await SeedFailedRunAsync(world, new ProviderApiException(ProviderKind.GitLab, 401, "ListPullRequestsAsync", "401", new Exception("x")), repositoryId: null, scopeRepositories: new[] { world.RepositoryId }); + + var remedy = (await RemedyAsync(world.OwnerId, world.TeamId, runId))!; + + remedy.Code.ShouldBe(FailureCodes.ProviderUnauthorized); + remedy.Credential!.Id.ShouldBe(world.CredentialId); + remedy.Repositories.ShouldHaveSingleItem().Id.ShouldBe(world.RepositoryId); + } + + [Fact] + public async Task An_agent_runs_own_failure_speaks_for_a_failed_run_whose_ledger_names_none_and_only_the_last_agent_run_counts() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var runId = await SeedRunAsync(world, WorkflowRunStatus.Failure); + + await SeedAgentRunAsync(world, runId, AgentRunStatus.Failed, createdMinutesAgo: 10, result: ResultOf(Failure: ProviderFailureCapture.From(new CredentialDisconnectedException(world.CredentialId, "n"), world.RepositoryId))); + (await RemedyAsync(world.OwnerId, world.TeamId, runId))!.Code.ShouldBe(FailureCodes.CredentialDisconnected, "the clone could not authenticate"); + + await SeedAgentRunAsync(world, runId, AgentRunStatus.Failed, createdMinutesAgo: 1, result: ResultOf(Failure: null)); + (await RemedyAsync(world.OwnerId, world.TeamId, runId)).ShouldBeNull("a later attempt that failed for another reason is why the run ended; the earlier credential is history"); + } + + [Fact] + public async Task An_agent_runs_failure_speaks_for_the_run_only_when_the_node_that_ended_the_run_is_the_one_that_spawned_it() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var disconnected = ResultOf(Failure: ProviderFailureCapture.From(new CredentialDisconnectedException(world.CredentialId, "n"), world.RepositoryId)); + + var endedOnAnotherNode = await SeedRunAsync(world, WorkflowRunStatus.Failure, error: "Node 'later' failed."); + await SeedAgentRunAsync(world, endedOnAnotherNode, AgentRunStatus.Failed, createdMinutesAgo: 5, result: disconnected); + await RecordNodeFailureAsync(endedOnAnotherNode, "later"); + + var endedOnTheAgent = await SeedRunAsync(world, WorkflowRunStatus.Failure, error: "Node 'agent' failed."); + await SeedAgentRunAsync(world, endedOnTheAgent, AgentRunStatus.Failed, createdMinutesAgo: 5, result: disconnected); + await RecordNodeFailureAsync(endedOnTheAgent, "agent"); + + (await RemedyAsync(world.OwnerId, world.TeamId, endedOnAnotherNode)).ShouldBeNull("the agent run's connection failure is history: the run ended on 'later', which recorded none"); + (await RemedyAsync(world.OwnerId, world.TeamId, endedOnTheAgent))!.Code.ShouldBe(FailureCodes.CredentialDisconnected, "the agent node ended the run and its own record names no failure, so its agent run's account is the reason"); + } + + [Fact] + public async Task A_handled_provider_failure_does_not_speak_for_a_run_the_reconciler_later_abandoned_or_one_that_failed_on_another_node() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var thrown = new CredentialDisconnectedException(world.CredentialId, "GitLab · nacho"); + + var abandoned = await SeedFailedRunAsync(world, thrown); + await SetRunErrorAsync(abandoned, "Run marked abandoned by reconciler — worker crashed or hung past the abandoned-run threshold with no ledger progress."); + + var failedLater = await SeedFailedRunAsync(world, thrown); + await RecordNodeFailureAsync(failedLater, "flaky"); + await SetRunErrorAsync(failedLater, "Node 'flaky' failed."); + + var failedOnIt = await SeedFailedRunAsync(world, thrown); + + (await RemedyAsync(world.OwnerId, world.TeamId, abandoned)).ShouldBeNull("the run's error names no node, so the failure on 'list' is not what ended it"); + (await RemedyAsync(world.OwnerId, world.TeamId, failedLater)).ShouldBeNull("the newest node to fail recorded no connection failure"); + (await RemedyAsync(world.OwnerId, world.TeamId, failedOnIt))!.Code.ShouldBe(FailureCodes.CredentialDisconnected, "control: the node the run's error names recorded it"); + } + + [Fact] + public async Task A_push_the_remote_refused_on_a_run_that_otherwise_finished_has_a_remedy_naming_the_repository() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var runId = await SeedRunAsync(world, WorkflowRunStatus.Success); + + await SeedAgentRunAsync(world, runId, AgentRunStatus.Succeeded, createdMinutesAgo: 1, result: ResultOf(PublishFailureCode: FailureCodes.GitCredentialRejected, RepositoryResultId: world.RepositoryId)); + + var remedy = (await RemedyAsync(world.OwnerId, world.TeamId, runId))!; + + remedy.Code.ShouldBe(FailureCodes.GitCredentialRejected); + remedy.Title.ShouldBe("GitLab refused the push"); + remedy.Repositories.ShouldHaveSingleItem().Id.ShouldBe(world.RepositoryId); + remedy.Credential!.Id.ShouldBe(world.CredentialId); + } + + [Fact] + public async Task A_finished_run_whose_agents_published_cleanly_has_no_remedy() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var runId = await SeedRunAsync(world, WorkflowRunStatus.Success); + + await SeedAgentRunAsync(world, runId, AgentRunStatus.Succeeded, createdMinutesAgo: 1, result: ResultOf()); + + (await RemedyAsync(world.OwnerId, world.TeamId, runId)).ShouldBeNull(); + } + + [Fact] + public async Task A_run_of_another_team_reads_as_nothing() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var other = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var runId = await SeedFailedRunAsync(world, new CredentialDisconnectedException(world.CredentialId, "n")); + + (await RemedyAsync(other.OwnerId, other.TeamId, runId)).ShouldBeNull("the run is not the asking team's, so it is indistinguishable from a run that does not exist"); + } + + [Fact] + public async Task Both_run_detail_reads_carry_the_remedy_for_the_asker_and_a_run_that_did_not_fail_carries_none() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var failed = await SeedFailedRunAsync(world, new CredentialDisconnectedException(world.CredentialId, "GitLab · nacho")); + var succeeded = await SeedRunAsync(world, WorkflowRunStatus.Success); + var viewer = await AddMemberAsync(world.TeamId, TeamRole.Viewer, "Vera Viewer"); + + using var scope = _fixture.BeginScopeAs(viewer, world.TeamId); + var mediator = scope.Resolve(); + + var byId = await mediator.Send(new CodeSpace.Messages.Queries.Workflows.GetWorkflowRunQuery { RunId = failed }); + var byRef = await mediator.Send(new CodeSpace.Messages.Queries.Workflows.GetWorkflowRunByRefQuery { IdOrNumber = failed.ToString() }); + + foreach (var detail in new[] { byId, byRef }) + { + detail!.Remedy.ShouldNotBeNull("the run failed on a disconnected credential"); + detail.Remedy.Code.ShouldBe(FailureCodes.CredentialDisconnected); + detail.Remedy.Actions.Where(a => a.Enabled).Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders }, "a Viewer's remedy, not an Owner's"); + detail.Error.ShouldBe("Node 'list' failed.", "the sentence is untouched"); + } + + (await mediator.Send(new CodeSpace.Messages.Queries.Workflows.GetWorkflowRunQuery { RunId = succeeded }))!.Remedy.ShouldBeNull(); + (await mediator.Send(new CodeSpace.Messages.Queries.Workflows.GetWorkflowRunRemedyQuery { RunId = failed }))!.Code.ShouldBe(FailureCodes.CredentialDisconnected); + } + + // ─── The refused deliveries a repository's trigger carries ───────────────── + + [Fact] + public async Task Deliveries_refused_for_the_credential_carry_a_remedy_with_no_run_to_run_again() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.CredentialRejected, "GitLab answered HTTP 401 to the repository's credential 'GitLab · nacho'", minutesAgo: 3); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.SignatureInvalid, "HMAC mismatch", minutesAgo: 1); + + var remedy = (await RefusedRemedyAsync(world.OwnerId, world, world.RepositoryId))!; + + remedy.Code.ShouldBe(WorkflowRunRequestRejectionReasons.CredentialRejected, "the newest refusal that blames the credential — a signature failure blames nothing here"); + remedy.Title.ShouldBe("GitLab refused the credential this trigger uses"); + remedy.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.Reconnect, RemedyActionKind.Relink, RemedyActionKind.OpenProviders }, "a refused delivery started no run, so there is nothing to run again"); + remedy.Credential!.Id.ShouldBe(world.CredentialId); + } + + [Fact] + public async Task A_standing_the_provider_would_not_answer_says_what_the_credential_needs_and_is_never_resolved_by_a_live_row() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "GitLab refused the lookup (HTTP 403)", minutesAgo: 3); + + var remedy = (await RefusedRemedyAsync(world.OwnerId, world, world.RepositoryId))!; + + remedy.Code.ShouldBe(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown); + remedy.Cause.ShouldContain("read_api scope"); + remedy.Resolved.ShouldBeFalse("a 403 on a credential that looks fine is only believed fixed once something validated it since"); + } + + [Theory] + // what the refusal kept, what the card must offer + [InlineData(StandingLookupCauses.AccessRefused, "403", true, "lacks the read_api scope or a role on the project")] + [InlineData(StandingLookupCauses.NoAnswer, "503", false, "is not a verdict on")] + [InlineData(StandingLookupCauses.NoAnswer, null, false, "did not answer in time")] + public async Task A_standing_the_provider_could_not_give_names_what_failed_and_sends_the_credentials_person_to_repair_it_only_when_it_is_the_credential(string lookup, string? status, bool repairsTheCredential, string says) + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "GitLab answered", minutesAgo: 3, metadata: LookupMetadata(lookup, status)); + + var remedy = (await RefusedRemedyAsync(world.OwnerId, world, world.RepositoryId))!; + var kinds = remedy.Actions.Select(a => a.Kind).ToList(); + + remedy.Cause.ShouldContain(says); + remedy.Cause.ShouldContain("Anyone is not the fix"); + kinds.Contains(RemedyActionKind.Reconnect).ShouldBe(repairsTheCredential, "a lookup that got no answer is not a reason to repair a credential nobody refused"); + kinds.Contains(RemedyActionKind.Relink).ShouldBe(repairsTheCredential); + kinds.ShouldContain(RemedyActionKind.OpenProviders); + if (status is not null) remedy.Cause.ShouldContain($"(HTTP {status})"); + } + + [Fact] + public async Task A_repository_that_had_no_credential_to_ask_with_is_offered_a_re_link_and_nothing_to_reconnect() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await UnbindAsync(world.RepositoryId); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "the repository has no credential bound to ask the provider with", minutesAgo: 3, metadata: LookupMetadata(StandingLookupCauses.NoCredential, null)); + + var remedy = (await RefusedRemedyAsync(world.OwnerId, world, world.RepositoryId))!; + + remedy.Title.ShouldBe("There is no credential to ask GitLab who wrote the pull request with"); + remedy.Actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.Relink, RemedyActionKind.OpenProviders }); + } + + [Fact] + public async Task A_standing_refusal_written_before_the_cause_was_kept_reads_as_either_and_keeps_the_repair() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "GitLab refused the lookup (HTTP 403)", minutesAgo: 3); + + var remedy = (await RefusedRemedyAsync(world.OwnerId, world, world.RepositoryId))!; + + remedy.Cause.ShouldContain("can also be a timeout or an error"); + remedy.Actions.Select(a => a.Kind).ShouldContain(RemedyActionKind.Reconnect, "what it always offered: nothing is known that says the credential is fine"); + } + + [Fact] + public async Task Refusals_that_blame_nothing_about_the_credential_carry_no_remedy() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.SignatureInvalid, "HMAC mismatch", minutesAgo: 1); + await SeedRefusalAsync(world, WorkflowRunRequestRejectionReasons.AuthorNotMember, "not a member", minutesAgo: 2); + + (await RefusedRemedyAsync(world.OwnerId, world, world.RepositoryId)).ShouldBeNull(); + } + + // ─── The credential list ─────────────────────────────────────────────────── + + [Fact] + public async Task The_credential_list_returns_every_state_a_person_may_need_to_repair_with_how_many_repositories_each_holds() + { + var world = await SeedCredentialWorldAsync(owner: TeamRole.Member); + var revoked = await AddCredentialAsync(world, CredentialStatus.Revoked, "GitLab · disconnected"); + var errored = await AddCredentialAsync(world, CredentialStatus.Error, "GitLab · refused"); + var retired = await AddCredentialAsync(world, CredentialStatus.Revoked, "GitLab · retired", deleted: true); + await AddRepositoryAsync(world, revoked); + await AddRepositoryAsync(world, revoked); + await AddRepositoryAsync(world, errored); + + using var scope = _fixture.BeginScopeAs(world.OwnerId, world.TeamId); + var list = await scope.Resolve().ListAsync(null, CancellationToken.None); + + list.Single(c => c.Id == world.CredentialId).BoundRepositoryCount.ShouldBe(1); + list.Single(c => c.Id == revoked).BoundRepositoryCount.ShouldBe(2); + list.Single(c => c.Id == revoked).Health.ShouldBe(CredentialHealth.Revoked, "a disconnected credential is still listed, so a person can see what is stranded"); + list.Single(c => c.Id == errored).Health.ShouldBe(CredentialHealth.Rejected); + list.Single(c => c.Id == errored).BoundRepositoryCount.ShouldBe(1); + list.ShouldNotContain(c => c.Id == retired, "a retired credential is gone from the list; what leans on it is reached through its repositories"); + } + + // ─── Seeds ───────────────────────────────────────────────────────────────── + + private sealed record World(Guid TeamId, Guid OwnerId, Guid CredentialOwnerId, string CredentialOwnerName, Guid InstanceId, Guid CredentialId, Guid RepositoryId); + + /// A team with an Owner, an Admin, and a person ('s role) who owns the one credential, which a repository runs through. + private async Task SeedCredentialWorldAsync(TeamRole owner, AuthType authType = AuthType.OAuth) + { + var team = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var admin = await AddMemberAsync(team.TeamId, TeamRole.Admin, "Ada Admin"); + var nacho = await AddMemberAsync(team.TeamId, owner, "Nacho"); + var nachoName = await NameOfAsync(nacho); + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var instance = new ProviderInstance { Id = Guid.NewGuid(), TeamId = team.TeamId, Provider = ProviderKind.GitLab, DisplayName = "gitlab", BaseUrl = $"http://127.0.0.1:1/{suffix}", ApiUrl = $"http://127.0.0.1:1/{suffix}" }; + var credential = new Credential { Id = Guid.NewGuid(), TeamId = team.TeamId, ProviderInstanceId = instance.Id, OwnerUserId = nacho, Ownership = CredentialOwnership.Personal, AuthType = authType, DisplayName = "GitLab · nacho", EncryptedPayload = "x", Status = CredentialStatus.Error, LastError = "needs reconnect: invalid_grant" }; + var repository = NewRepository(team.TeamId, instance.Id, credential.Id, "acme/api"); + + db.ProviderInstance.Add(instance); + db.Credential.Add(credential); + db.Repository.Add(repository); + await db.SaveChangesAsync(); + + _ = admin; + + return new World(team.TeamId, team.UserId, nacho, nachoName, instance.Id, credential.Id, repository.Id); + } + + private async Task NameOfAsync(Guid userId) + { + using var scope = _fixture.BeginScope(); + + return await scope.Resolve().User.IgnoreQueryFilters().AsNoTracking().Where(u => u.Id == userId).Select(u => u.Name).SingleAsync(); + } + + private static Repository NewRepository(Guid teamId, Guid instanceId, Guid? credentialId, string path) => new() + { + Id = Guid.NewGuid(), TeamId = teamId, ProviderInstanceId = instanceId, CredentialId = credentialId, ExternalId = Random.Shared.Next(100_000, 999_999).ToString(), NamespacePath = path.Split('/')[0], Name = path.Split('/')[1], FullPath = path, + DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = $"http://127.0.0.1:1/{path}", Status = RepositoryStatus.Error, + }; + + private async Task AddMemberAsync(Guid teamId, TeamRole role, string name) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var userId = Guid.NewGuid(); + + db.User.Add(new User { Id = userId, Email = $"remedy-{userId:N}@test.local", Name = $"{name} {userId.ToString("N")[..4]}", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + db.TeamMembership.Add(new TeamMembership { Id = Guid.NewGuid(), TeamId = teamId, UserId = userId, Role = role, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + await db.SaveChangesAsync(); + + return userId; + } + + private async Task BaseUrlOfAsync(Guid instanceId) + { + using var scope = _fixture.BeginScope(); + + return await scope.Resolve().ProviderInstance.AsNoTracking().Where(i => i.Id == instanceId).Select(i => i.BaseUrl).SingleAsync(); + } + + private async Task SetBaseUrlAsync(Guid instanceId, string baseUrl) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Database.ExecuteSqlInterpolatedAsync($"UPDATE provider_instance SET base_url = {baseUrl} WHERE id = {instanceId}"); + } + + private async Task RetireAsync(Guid credentialId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Database.ExecuteSqlInterpolatedAsync($"UPDATE credential SET status = 'Revoked', deleted_date = now() WHERE id = {credentialId}"); + } + + private async Task AddCredentialAsync(World world, CredentialStatus status, string name, bool deleted = false) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var credential = new Credential { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = world.InstanceId, OwnerUserId = world.CredentialOwnerId, AuthType = AuthType.OAuth, DisplayName = name, EncryptedPayload = "x", Status = status, DeletedDate = deleted ? DateTimeOffset.UtcNow : null }; + + db.Credential.Add(credential); + await db.SaveChangesAsync(); + + return credential.Id; + } + + private async Task AddRepositoryAsync(World world, Guid credentialId) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + db.Repository.Add(NewRepository(world.TeamId, world.InstanceId, credentialId, $"acme/r{Guid.NewGuid().ToString("N")[..6]}")); + await db.SaveChangesAsync(); + } + + /// A failed run whose node recorded the failure the real exception declares, written by the real ledger writer. + private async Task SeedFailedRunAsync(World world, Exception thrown, Guid? repositoryId = null, Guid[]? scopeRepositories = null) + { + var runId = await SeedRunAsync(world, WorkflowRunStatus.Failure, scopeRepositories); + var failure = ProviderFailureCapture.From(thrown, repositoryId ?? (scopeRepositories is null ? world.RepositoryId : null)); + + using var scope = _fixture.BeginScope(); + + await scope.Resolve().NodeFailedAsync(runId, "list", string.Empty, thrown.Message, TimeSpan.FromMilliseconds(5), failure, CancellationToken.None); + + return runId; + } + + private async Task SeedRunAsync(World world, WorkflowRunStatus status, Guid[]? scopeRepositories = null, string? error = null) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var requestId = Guid.NewGuid(); + var runId = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + + db.WorkflowRunRequest.Add(new WorkflowRunRequest { Id = requestId, TeamId = world.TeamId, SourceType = WorkflowRunSourceTypes.Snapshot, ActorType = "user", ActorId = SystemUsers.SeederId, NormalizedPayloadJson = "{}", Status = WorkflowRunRequestStatus.Consumed, ReceivedAt = now, VerifiedAt = now, NormalizedAt = now }); + db.WorkflowRun.Add(new WorkflowRun + { + Id = runId, TeamId = world.TeamId, RunRequestId = requestId, SourceType = WorkflowRunSourceTypes.Snapshot, Status = status, Error = error ?? (status == WorkflowRunStatus.Failure ? "Node 'list' failed." : null), + ScopeRepositoryIds = scopeRepositories?.ToList() ?? [], ScopeProjectIds = [], DefinitionSnapshotJson = "{\"nodes\":[],\"edges\":[]}", DefinitionSnapshotHash = "sha256:test", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId, + }); + await db.SaveChangesAsync(); + + return runId; + } + + /// A node that failed with no provider failure — written by the real ledger writer, after any record already there. + private async Task RecordNodeFailureAsync(Guid runId, string nodeId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().NodeFailedAsync(runId, nodeId, string.Empty, "it failed", TimeSpan.FromMilliseconds(5), CancellationToken.None); + } + + private async Task SetRunErrorAsync(Guid runId, string error) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Database.ExecuteSqlInterpolatedAsync($"UPDATE workflow_run SET error = {error} WHERE id = {runId}"); + } + + private static string ResultOf(PersistedFailure? Failure = null, string? PublishFailureCode = null, Guid? RepositoryResultId = null) + { + var result = new AgentRunResult + { + Status = AgentRunStatus.Succeeded, ExitReason = "completed", Failure = Failure, PublishFailureCode = RepositoryResultId is null ? PublishFailureCode : null, + RepositoryResults = RepositoryResultId is { } id ? new[] { new RepositoryRunResult { Alias = "api", RepositoryId = id, PublishFailureCode = PublishFailureCode } } : Array.Empty(), + }; + + return System.Text.Json.JsonSerializer.Serialize(result, CodeSpace.Core.Services.Agents.AgentJson.Options); + } + + private async Task SeedAgentRunAsync(World world, Guid runId, AgentRunStatus status, int createdMinutesAgo, string result) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var at = DateTimeOffset.UtcNow.AddMinutes(-createdMinutesAgo); + + db.AgentRun.Add(new AgentRun { Id = Guid.NewGuid(), TeamId = world.TeamId, WorkflowRunId = runId, NodeId = "agent", Harness = "claude-cli", Status = status, ResultJson = result, CompletedAt = at, CreatedDate = at, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + await db.SaveChangesAsync(); + } + + private static string LookupMetadata(string lookup, string? status) => CodeSpace.Core.Services.Workflows.Failures.PersistedFailureJson.AsPayload(new PersistedFailure + { + Code = WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, + Details = status is null ? new Dictionary { [StandingLookupCauses.DetailKey] = lookup } : new Dictionary { [StandingLookupCauses.DetailKey] = lookup, ["providerStatus"] = status } + }); + + private async Task UnbindAsync(Guid repositoryId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Database.ExecuteSqlInterpolatedAsync($"UPDATE repository SET credential_id = NULL WHERE id = {repositoryId}"); + } + + private async Task SeedRefusalAsync(World world, string reason, string detail, int minutesAgo, string? metadata = null) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + db.WorkflowRunRequest.Add(new WorkflowRunRequest + { + Id = Guid.NewGuid(), TeamId = world.TeamId, RepositoryId = world.RepositoryId, SourceType = $"{WorkflowRunSourceTypes.ProviderPrefix}gitlab", ExternalEventId = Guid.NewGuid().ToString("N"), ActorType = WorkflowRunActorTypes.Webhook, + Status = WorkflowRunRequestStatus.Rejected, Error = $"{reason}: {detail}", ReceivedAt = DateTimeOffset.UtcNow.AddMinutes(-minutesAgo), RequestMetadataJson = metadata ?? "{}", + }); + await db.SaveChangesAsync(); + } + + private async Task RemedyAsync(Guid askerId, Guid teamId, Guid runId) + { + using var scope = _fixture.BeginScopeAs(askerId, teamId); + + return await scope.Resolve().GetForRunAsync(runId, teamId, CancellationToken.None); + } + + private async Task RefusedRemedyAsync(Guid askerId, World world, Guid repositoryId) + { + using var scope = _fixture.BeginScopeAs(askerId, world.TeamId); + var refused = await scope.Resolve().ListForRepositoryAsync(repositoryId, CancellationToken.None); + + return await scope.Resolve().GetForRefusedDeliveriesAsync(repositoryId, refused.Deliveries, world.TeamId, CancellationToken.None); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs index 1ea6ddfdd..8b7c6e887 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Sessions/RoomProjectorFlowTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Autofac; +using CodeSpace.Core.Failures; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents; @@ -24,6 +25,8 @@ using CodeSpace.Messages.Decisions; using CodeSpace.Messages.Dtos.Sessions.Room; using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using CodeSpace.Messages.Failures; using CodeSpace.Messages.Plans; using CodeSpace.Messages.Quality; using CodeSpace.Messages.Tasks; @@ -1344,6 +1347,51 @@ public async Task A_plain_failed_runs_own_agent_card_names_the_cause_not_just_th .ShouldBe("litellm.BadRequestError: Unexpected message role", "the field reaches the frontend on the wire, not only in the CLR projection"); } + [Fact] + public async Task A_run_that_failed_on_a_provider_connection_carries_the_failure_code_and_is_not_told_as_a_model_credential_problem() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var sessionId = await SeedSessionAsync(teamId, "Disconnected"); + var run = await SeedTurnAsync(teamId, sessionId, turn: 1, goal: "Review the MR", resultSummary: null, status: WorkflowRunStatus.Failure, error: "Node 'list' failed."); + var thrown = new CredentialDisconnectedException(Guid.NewGuid(), "GitLab · NACHO.L"); + + await RecordNodeFailureAsync(run, thrown.Message, ProviderFailureCapture.From(thrown, repositoryId: null)); + + var turn = (await ProjectByRunAsync(run, teamId))!.Blocks.OfType().Single(); + var diag = turn.Blocks.OfType().ShouldHaveSingleItem(); + + diag.FailureCode.ShouldBe("credential_disconnected", "the page asks for the live remedy by this code"); + diag.Title.ShouldBe("The credential this run used was disconnected"); + diag.Text.ShouldBe(thrown.Message, "the connection's own words — the credential's name included"); + diag.Actions.ShouldNotContain(a => a.Kind == RoomActionKind.FixCredentials, "the model credentials are not what is broken, and the sentence's word 'credential' used to send people there"); + + var wire = JsonSerializer.SerializeToElement(turn.Blocks.OfType().Single(), ApiJson); + wire.GetProperty("failureCode").GetString().ShouldBe("credential_disconnected", "the field reaches the frontend on the wire"); + wire.EnumerateObject().Select(p => p.Name).ShouldNotContain("remedy", "who is asking and what has been fixed since are not in the cached turn — only the code is"); + } + + [Fact] + public async Task A_failed_run_recorded_before_failures_were_kept_reads_exactly_as_it_did() + { + var (teamId, _) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + var sessionId = await SeedSessionAsync(teamId, "Legacy"); + var run = await SeedTurnAsync(teamId, sessionId, turn: 1, goal: "Review the MR", resultSummary: null, status: WorkflowRunStatus.Failure, error: "Node 'list' failed."); + + await RecordNodeFailureAsync(run, "The credential 'GitLab · NACHO.L' was disconnected, so nothing bound through it can authenticate.", failure: null); + + var diag = (await ProjectByRunAsync(run, teamId))!.Blocks.OfType().Single().Blocks.OfType().ShouldHaveSingleItem(); + + diag.FailureCode.ShouldBeNull("nothing recorded the code, so nothing claims one"); + diag.Title.ShouldBe("Authentication failed", "an old row keeps the reading it always had"); + } + + private async Task RecordNodeFailureAsync(Guid runId, string error, PersistedFailure? failure) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().NodeFailedAsync(runId, "list", string.Empty, error, TimeSpan.FromMilliseconds(3), failure, CancellationToken.None); + } + [Fact] public async Task A_plain_succeeded_runs_own_agent_card_carries_no_error() { diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.ProviderFailure.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.ProviderFailure.cs new file mode 100644 index 000000000..4b1b15c65 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/AgentRunExecutorTests.ProviderFailure.cs @@ -0,0 +1,77 @@ +using Autofac; +using CodeSpace.Core.Services.Agents; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Messages.Agents; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using CodeSpace.Messages.Failures; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +public partial class AgentRunExecutorTests +{ + // 🟢 High fidelity — the real executor over real Postgres. A repository whose credential was disconnected cannot be cloned; the run + // fails, and what it says about WHY is now a code and what it names, not only the sentence. + [Fact] + public async Task A_run_whose_clone_met_a_disconnected_credential_records_the_failure_with_the_repository_beside_the_sentence() + { + var teamId = await SeedTeamAsync(); + var repositoryId = Guid.NewGuid(); + var credentialId = Guid.NewGuid(); + var runId = await CreateTaskRunAsync(teamId, new AgentTask { Goal = "scripted", Harness = "scripted", Model = "test-model", TimeoutSeconds = 60, RepositoryId = repositoryId }); + + using (var scope = _fixture.BeginScope(b => b.RegisterInstance(new DisconnectedCredentialResolver(credentialId)).As())) + await NewExecutor(scope, new ScriptedHarness("true")).ExecuteAsync(runId, CancellationToken.None); + + using var read = _fixture.BeginScope(); + var run = await read.Resolve().GetAsync(runId, CancellationToken.None); + var result = System.Text.Json.JsonSerializer.Deserialize(run.ResultJson!, AgentJson.Options)!; + + run.Status.ShouldBe(AgentRunStatus.Failed); + result.ExitReason.ShouldBe(FailureCodes.CredentialDisconnected, "the exit reason already carried the code"); + result.Failure.ShouldNotBeNull(); + result.Failure.Code.ShouldBe(FailureCodes.CredentialDisconnected); + result.Failure.DetailGuid("credentialId").ShouldBe(credentialId, "and now what it names too"); + result.Failure.RepositoryId.ShouldBe(repositoryId, "the repository the run was working in"); + result.Error.ShouldContain("was disconnected", customMessage: "the sentence is untouched"); + } + + [Fact] + public async Task A_run_that_failed_for_another_reason_records_no_failure_so_its_result_reads_as_it_always_did() + { + var teamId = await SeedTeamAsync(); + var runId = await CreateTaskRunAsync(teamId, new AgentTask { Goal = "scripted", Harness = "scripted", Model = "test-model", TimeoutSeconds = 60, RepositoryId = Guid.NewGuid() }); + + using (var scope = _fixture.BeginScope(b => b.RegisterInstance(new ThrowingResolver(new InvalidOperationException("the clone broke"))).As())) + await NewExecutor(scope, new ScriptedHarness("true")).ExecuteAsync(runId, CancellationToken.None); + + using var read = _fixture.BeginScope(); + var run = await read.Resolve().GetAsync(runId, CancellationToken.None); + + run.ResultJson!.ShouldNotContain("\"failure\"", Case.Insensitive, "an absent failure is omitted, never written as null"); + System.Text.Json.JsonSerializer.Deserialize(run.ResultJson!, AgentJson.Options)!.Failure.ShouldBeNull(); + } + + private sealed class DisconnectedCredentialResolver : IAgentWorkspaceResolver + { + private readonly Guid _credentialId; + + public DisconnectedCredentialResolver(Guid credentialId) { _credentialId = credentialId; } + + public Task ResolveAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken) => throw new CredentialDisconnectedException(_credentialId, "GitLab · nacho"); + + public Task ResolveByRepositoryIdAsync(Guid repositoryId, Guid teamId, CancellationToken cancellationToken, string? @ref = null, bool softFallback = false, string? pinnedSha = null) => throw new CredentialDisconnectedException(_credentialId, "GitLab · nacho"); + } + + private sealed class ThrowingResolver : IAgentWorkspaceResolver + { + private readonly Exception _failure; + + public ThrowingResolver(Exception failure) { _failure = failure; } + + public Task ResolveAsync(AgentTask task, Guid teamId, CancellationToken cancellationToken) => throw _failure; + + public Task ResolveByRepositoryIdAsync(Guid repositoryId, Guid teamId, CancellationToken cancellationToken, string? @ref = null, bool softFallback = false, string? pinnedSha = null) => throw _failure; + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/PullRequestTriggerAdmissionFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/PullRequestTriggerAdmissionFlowTests.cs index 3ac8faeb8..6b829178f 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/PullRequestTriggerAdmissionFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/PullRequestTriggerAdmissionFlowTests.cs @@ -3,6 +3,7 @@ using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Credentials; +using CodeSpace.Core.Services.Workflows.Failures; using CodeSpace.IntegrationTests.Infrastructure; using CodeSpace.IntegrationTests.Webhooks; using CodeSpace.IntegrationTests.Workflows.Infrastructure; @@ -12,6 +13,7 @@ using CodeSpace.Messages.Enums; using CodeSpace.Messages.Events; using CodeSpace.Messages.Events.PullRequest; +using CodeSpace.Messages.Failures; using MediatR; using Microsoft.EntityFrameworkCore; using Shouldly; @@ -155,10 +157,11 @@ public async Task On_a_public_gitlab_project_a_push_by_someone_else_to_a_fork_mr } [Theory] - [InlineData(403, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "refused the lookup (HTTP 403)")] - [InlineData(503, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "answered HTTP 503 to the lookup")] - [InlineData(401, WorkflowRunRequestRejectionReasons.CredentialRejected, "answered HTTP 401 to the repository's credential")] - public async Task On_a_public_gitlab_project_a_pusher_whose_standing_could_not_be_read_is_refused_for_that_not_as_a_stranger(int status, string reason, string cause) + [InlineData(403, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "refused the lookup (HTTP 403)", StandingLookupCauses.AccessRefused, "403")] + [InlineData(503, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "answered HTTP 503 to the lookup", StandingLookupCauses.NoAnswer, "503")] + [InlineData(429, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "answered HTTP 429 to the lookup", StandingLookupCauses.NoAnswer, "429")] + [InlineData(401, WorkflowRunRequestRejectionReasons.CredentialRejected, "answered HTTP 401 to the repository's credential", null, null)] + public async Task On_a_public_gitlab_project_a_pusher_whose_standing_could_not_be_read_is_refused_for_that_not_as_a_stranger(int status, string reason, string cause, string? lookup, string? providerStatus) { // The author is a known member; whoever else pushed to their fork's head has to be a member too, and asking GitLab // about them failed. Which refusal that is matters: "their standing is unknown, set Pull requests from to Anyone" sends @@ -178,6 +181,29 @@ public async Task On_a_public_gitlab_project_a_pusher_whose_standing_could_not_b refusal.ShouldContain("whoever pushed its new commits", customMessage: "the refusal says whose standing could not be read — here the pusher's, the author's being known"); refusal.ShouldContain(cause); (await LoadRefusalsAsync(seed.TeamId, WorkflowRunRequestRejectionReasons.AuthorNotMember)).ShouldBeEmpty("nothing is known about the pusher, so no one was refused as a stranger"); + + // The structured half the repository's remedy reads: which lookup failed and what the provider answered — a 403 is the credential's to fix, a 503 or a 429 is not. + var kept = await LoadRefusalFailureAsync(seed.TeamId, reason); + + kept?.Detail(StandingLookupCauses.DetailKey).ShouldBe(lookup, "the cause the refusal kept"); + kept?.Detail("providerStatus").ShouldBe(providerStatus); + (kept is null).ShouldBe(lookup is null, "a refusal blamed on the credential outright keeps no lookup cause"); + } + + [Fact] + public async Task A_repository_with_no_credential_bound_keeps_that_as_why_its_authors_standing_was_never_asked() + { + using var gitlab = new StubProviderHost(); + var seed = await SeedAsync(ProviderKind.GitLab, gitlab.BaseUrl); + await UnbindCredentialAsync(seed.RepositoryId); + await SeedActivationAsync(seed, Opened, "{}"); + + await PublishAsync(Event(Opened, seed.RepositoryId, number: 21, new PullRequestOrigin { AuthorExternalId = "51", RepositoryVisibility = RepositoryVisibility.Public, IsFork = true })); + + (await CountRunsAsync(seed.WorkflowId)).ShouldBe(0); + gitlab.Requests.ShouldBeEmpty("there was no credential to ask with"); + + (await LoadRefusalFailureAsync(seed.TeamId, WorkflowRunRequestRejectionReasons.AuthorStandingUnknown))!.Detail(StandingLookupCauses.DetailKey).ShouldBe(StandingLookupCauses.NoCredential); } [Fact] @@ -427,6 +453,24 @@ private async Task> LoadPayloadsAsync(Guid workflowId, string field return payloads.Select(p => JsonDocument.Parse(p).RootElement.GetProperty(field).GetString()!).ToList(); } + private async Task LoadRefusalFailureAsync(Guid teamId, string reason) + { + using var scope = _fixture.BeginScope(); + var metadata = await scope.Resolve().WorkflowRunRequest.AsNoTracking() + .Where(r => r.TeamId == teamId && r.Status == WorkflowRunRequestStatus.Rejected && r.Error!.StartsWith(reason + ":")) + .Select(r => r.RequestMetadataJson) + .SingleAsync(); + + return PersistedFailureJson.Read(metadata); + } + + private async Task UnbindCredentialAsync(Guid repositoryId) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Database.ExecuteSqlInterpolatedAsync($"UPDATE repository SET credential_id = NULL WHERE id = {repositoryId}"); + } + private async Task> LoadRefusalsAsync(Guid teamId, string reason) { using var scope = _fixture.BeginScope(); diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs index 4c734a5e9..484d5141c 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs @@ -130,6 +130,15 @@ public void The_wire_codes_are_pinned() FailureCodes.SandboxSealedEgressUnavailable.ShouldBe("sandbox_sealed_egress_unavailable"); } + [Fact] + public void The_provider_connection_failures_are_pinned_member_by_member() + { + // These are the failures a run's record writes down and the run page answers with a remedy. A member added is a new + // kind of card with its own copy and its own buttons; one removed leaves a failure back as the sentence nobody can act on. + FailureCodes.ProviderConnection.ShouldBe(new[] { "credential_disconnected", "credential_needs_reconnect", "provider_unauthorized", "git_credential_rejected", "provider_base_url_redirects", "oauth_exchange_failed" }, ignoreOrder: true); + FailureCodes.ProviderConnection.ShouldBeSubsetOf(FailureCodes.All, "every persisted failure code is a declared one"); + } + [Fact] public void The_infra_exit_reasons_are_pinned_member_by_member() { diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyActionsTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyActionsTests.cs new file mode 100644 index 000000000..fcf255bd5 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyActionsTests.cs @@ -0,0 +1,217 @@ +using System.Linq; +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using Shouldly; + +namespace CodeSpace.UnitTests.Credentials.Remedy; + +/// +/// What a remedy offers, and to whom — the per-code, per-role matrix. A button must be enabled exactly when the endpoint behind +/// it would accept the request, and disabled with a reason (never hidden) when it would not, so a person who cannot fix a +/// connection still learns that it is fixable and by whom. +/// +[Trait("Category", "Unit")] +public class RemedyActionsTests +{ + private static readonly RemedyActions.ActionSubjects Everything = new(HasCredential: true, HasRepositories: true, HasProviderInstance: true, OffersRerun: true); + + private static RemedyFacts Facts(bool staticToken = false, bool retired = false, bool resolved = false, bool teamService = false, bool observable = true) => new() + { + Provider = "GitLab", CredentialName = "GitLab · nacho", OwnerName = "Nacho", CredentialIsStaticToken = staticToken, CredentialRetired = retired, CredentialIsTeamService = teamService, + Repositories = "acme/api", RepositoryCount = 1, Resolved = resolved, FixIsObservable = observable, RedirectsTo = "https://gitlab.example.com" + }; + + private static RemedyAction Of(IReadOnlyList actions, RemedyActionKind kind) => actions.Single(a => a.Kind == kind); + + [Theory] + // role, owns the credential, may re-link, may reconnect (OAuth init), may replace a token, may launch runs + [InlineData(TeamRole.Owner, false, true, true, true, true)] + [InlineData(TeamRole.Admin, false, true, true, true, true)] + [InlineData(TeamRole.Member, false, false, false, false, true)] + [InlineData(TeamRole.Member, true, false, false, true, true)] // POST /api/credentials/oauth/init needs credentials.manage whoever owns the row; only the token replace passes an owner on membership alone + [InlineData(TeamRole.Viewer, false, false, false, false, false)] + [InlineData(TeamRole.Viewer, true, false, false, true, false)] // a Viewer's own token is still theirs to replace — and their sign-in is not theirs to reconnect + public void The_caller_standing_follows_the_rules_of_the_endpoints_behind_the_buttons(TeamRole role, bool owns, bool relink, bool reconnect, bool replace, bool launch) + { + var standing = CallerStanding.Of(role, owns); + + standing.MayManageRepositories.ShouldBe(relink); + standing.MayReconnectTheCredential.ShouldBe(reconnect); + standing.MayReplaceTheToken.ShouldBe(replace); + standing.MayLaunchRuns.ShouldBe(launch); + } + + [Theory] + [InlineData(FailureCodes.CredentialDisconnected)] + [InlineData(FailureCodes.CredentialNeedsReconnect)] + [InlineData(FailureCodes.ProviderUnauthorized)] + [InlineData(FailureCodes.GitCredentialRejected)] + public void An_owner_is_offered_every_credential_fix_enabled_and_a_live_credential_is_repaired_in_place_first(string code) + { + var actions = RemedyActions.Build(code, Facts(), CallerStanding.Of(TeamRole.Owner, ownsTheCredential: false), Everything); + + actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.Reconnect, RemedyActionKind.Relink, RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }, "repairing in place moves nothing, so it leads; re-linking is the fallback"); + actions.Where(a => a.Kind != RemedyActionKind.Rerun).ShouldAllBe(a => a.Enabled); + } + + [Fact] + public void A_pasted_token_is_replaced_rather_than_reconnected() + { + var actions = RemedyActions.Build(FailureCodes.CredentialNeedsReconnect, Facts(staticToken: true), CallerStanding.Of(TeamRole.Owner, false), Everything); + + actions.Select(a => a.Kind).ShouldContain(RemedyActionKind.ReplaceToken); + actions.Select(a => a.Kind).ShouldNotContain(RemedyActionKind.Reconnect); + } + + [Fact] + public void A_retired_credential_cannot_be_reconnected_so_re_linking_is_all_there_is() + { + var actions = RemedyActions.Build(FailureCodes.CredentialDisconnected, Facts(retired: true), CallerStanding.Of(TeamRole.Owner, false), Everything); + + actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.Relink, RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }); + } + + [Fact] + public void A_member_who_does_not_own_the_credential_sees_the_fix_disabled_with_who_can_do_it() + { + var actions = RemedyActions.Build(FailureCodes.CredentialNeedsReconnect, Facts(), CallerStanding.Of(TeamRole.Member, ownsTheCredential: false), Everything); + + var reconnect = Of(actions, RemedyActionKind.Reconnect); + reconnect.Enabled.ShouldBeFalse(); + reconnect.DisabledReason.ShouldBe("Needs the Admin or Owner role.", "the sign-in flow is a credentials.manage request whoever the credential belongs to"); + + var replace = Of(RemedyActions.Build(FailureCodes.CredentialNeedsReconnect, Facts(staticToken: true), CallerStanding.Of(TeamRole.Member, ownsTheCredential: false), Everything), RemedyActionKind.ReplaceToken); + replace.Enabled.ShouldBeFalse(); + replace.DisabledReason.ShouldBe("Only Nacho, or a team Admin, can do this."); + + var relink = Of(actions, RemedyActionKind.Relink); + relink.Enabled.ShouldBeFalse(); + relink.DisabledReason.ShouldBe("Needs the Admin or Owner role."); + + Of(actions, RemedyActionKind.OpenProviders).Enabled.ShouldBeTrue("looking at the connections is every member's"); + } + + [Fact] + public void A_member_whose_own_pasted_token_it_is_can_replace_it_but_not_re_link() + { + var actions = RemedyActions.Build(FailureCodes.CredentialNeedsReconnect, Facts(staticToken: true), CallerStanding.Of(TeamRole.Member, ownsTheCredential: true), Everything); + + Of(actions, RemedyActionKind.ReplaceToken).Enabled.ShouldBeTrue("the token replace endpoint passes the credential's own person on membership alone"); + Of(actions, RemedyActionKind.Relink).Enabled.ShouldBeFalse(); + } + + [Theory] + [InlineData(TeamRole.Member)] + [InlineData(TeamRole.Viewer)] + public void A_non_admin_whose_own_oauth_sign_in_it_is_is_not_offered_a_reconnect_that_the_endpoint_would_refuse(TeamRole role) + { + var actions = RemedyActions.Build(FailureCodes.CredentialNeedsReconnect, Facts(), CallerStanding.Of(role, ownsTheCredential: true), Everything); + + var reconnect = Of(actions, RemedyActionKind.Reconnect); + reconnect.Enabled.ShouldBeFalse("InitCredentialOAuthCommand requires credentials.manage; the owner-on-membership rule belongs to the token replace only"); + reconnect.DisabledReason.ShouldBe("Needs the Admin or Owner role."); + } + + [Fact] + public void A_team_service_credential_names_the_role_that_can_repair_it_not_a_person() + { + var actions = RemedyActions.Build(FailureCodes.CredentialNeedsReconnect, Facts(staticToken: true, teamService: true), CallerStanding.Of(TeamRole.Member, false), Everything); + + Of(actions, RemedyActionKind.ReplaceToken).DisabledReason.ShouldBe("Needs the Admin or Owner role."); + } + + [Fact] + public void A_viewer_gets_nothing_enabled_but_the_providers_dialog() + { + var actions = RemedyActions.Build(FailureCodes.CredentialDisconnected, Facts(), CallerStanding.Of(TeamRole.Viewer, false), Everything); + + actions.Where(a => a.Enabled).Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders }); + Of(actions, RemedyActionKind.Rerun).DisabledReason.ShouldBe("Fix the connection first, then run it again."); + } + + [Fact] + public void A_redirect_is_fixed_by_editing_the_providers_address_not_by_touching_a_credential() + { + var facts = Facts() with { RedirectFix = RedirectRepair.EditAddress }; + var actions = RemedyActions.Build(FailureCodes.ProviderBaseUrlRedirects, facts, CallerStanding.Of(TeamRole.Admin, false), Everything); + + actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.EditProviderUrl, RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }); + Of(actions, RemedyActionKind.EditProviderUrl).Enabled.ShouldBeTrue(); + RemedyActions.Build(FailureCodes.ProviderBaseUrlRedirects, facts, CallerStanding.Of(TeamRole.Member, false), Everything).Single(a => a.Kind == RemedyActionKind.EditProviderUrl).Enabled.ShouldBeFalse(); + } + + [Theory] + [InlineData(RedirectRepair.DisconnectFirst)] + [InlineData(RedirectRepair.HostSide)] + public void A_redirect_the_server_would_refuse_to_edit_away_offers_no_edit_button_at_all(RedirectRepair fix) + { + var actions = RemedyActions.Build(FailureCodes.ProviderBaseUrlRedirects, Facts() with { RedirectFix = fix }, CallerStanding.Of(TeamRole.Owner, false), Everything); + + actions.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }, "a button that the endpoint answers 409 to is not a fix; the cause says what is"); + } + + [Fact] + public void Once_what_failed_works_again_running_it_again_leads_and_is_enabled_for_whoever_may_launch() + { + var actions = RemedyActions.Build(FailureCodes.CredentialDisconnected, Facts(resolved: true), CallerStanding.Of(TeamRole.Member, false), Everything); + + actions[0].Kind.ShouldBe(RemedyActionKind.Rerun); + actions[0].Enabled.ShouldBeTrue(); + RemedyActions.Build(FailureCodes.CredentialDisconnected, Facts(resolved: true), CallerStanding.Of(TeamRole.Viewer, false), Everything)[0].DisabledReason.ShouldBe("Needs the Member role or above."); + } + + [Fact] + public void A_provider_401_on_a_row_that_looks_fine_leaves_run_it_again_open_because_nobody_can_show_the_fix() + { + var actions = RemedyActions.Build(FailureCodes.ProviderUnauthorized, Facts(observable: false), CallerStanding.Of(TeamRole.Member, false), Everything); + + Of(actions, RemedyActionKind.Rerun).Enabled.ShouldBeTrue(); + } + + [Fact] + public void A_failure_that_says_nothing_about_the_credential_sends_nobody_to_repair_it() + { + // A renewal the provider did not answer (a final refusal would have been credential_needs_reconnect), and a standing lookup that got no answer. + var renewal = RemedyActions.Build(FailureCodes.OAuthExchangeFailed, Facts(observable: false), CallerStanding.Of(TeamRole.Owner, false), Everything); + var lookup = RemedyActions.Build(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, Facts() with { Lookup = StandingLookupCauses.NoAnswer }, CallerStanding.Of(TeamRole.Owner, false), Everything with { OffersRerun = false }); + + renewal.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }, "run it again; reconnecting a credential that was not refused is what to try only if that keeps failing"); + Of(renewal, RemedyActionKind.Rerun).Enabled.ShouldBeTrue("nothing is recorded that could show it fixed, so it is not held back"); + lookup.Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders }); + } + + [Fact] + public void A_standing_lookup_with_no_credential_to_ask_with_offers_to_link_one_and_nothing_to_reconnect() + { + var subjects = new RemedyActions.ActionSubjects(HasCredential: false, HasRepositories: true, HasProviderInstance: true, OffersRerun: false); + + RemedyActions.Build(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, Facts() with { Lookup = StandingLookupCauses.NoCredential }, CallerStanding.Of(TeamRole.Owner, false), subjects).Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.Relink, RemedyActionKind.OpenProviders }); + } + + [Fact] + public void A_refused_trigger_delivery_has_no_run_to_run_again() + { + var subjects = Everything with { OffersRerun = false }; + + RemedyActions.Build(WorkflowRunRequestRejectionReasons.CredentialRejected, Facts(), CallerStanding.Of(TeamRole.Owner, false), subjects).Select(a => a.Kind).ShouldNotContain(RemedyActionKind.Rerun); + } + + [Fact] + public void Actions_with_nothing_to_act_on_are_not_offered() + { + var subjects = new RemedyActions.ActionSubjects(HasCredential: false, HasRepositories: false, HasProviderInstance: false, OffersRerun: true); + + RemedyActions.Build(FailureCodes.ProviderUnauthorized, Facts(), CallerStanding.Of(TeamRole.Owner, false), subjects).Select(a => a.Kind).ShouldBe(new[] { RemedyActionKind.OpenProviders, RemedyActionKind.Rerun }); + } + + [Fact] + public void Several_repositories_make_the_re_link_say_so() + { + var actions = RemedyActions.Build(FailureCodes.CredentialDisconnected, Facts(retired: true) with { RepositoryCount = 3 }, CallerStanding.Of(TeamRole.Admin, false), Everything); + + Of(actions, RemedyActionKind.Relink).Label.ShouldBe("Re-link repositories"); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyCopyTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyCopyTests.cs new file mode 100644 index 000000000..8c7f1053e --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyCopyTests.cs @@ -0,0 +1,131 @@ +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Failures; +using Shouldly; + +namespace CodeSpace.UnitTests.Credentials.Remedy; + +/// +/// The words a person reads. Each code says what is wrong in plain language, names the credential and the repository when they are +/// known, leaves out what is not, and never carries a token or a status table. +/// +[Trait("Category", "Unit")] +public class RemedyCopyTests +{ + private static RemedyFacts Facts() => new() { Provider = "GitLab", CredentialName = "GitLab · NACHO.L", Repositories = "acme/api", RepositoryCount = 1, RequestedOrigin = "http://gitlab.example.com", RedirectsTo = "https://gitlab.example.com", ProviderStatus = "401", Lookup = StandingLookupCauses.AccessRefused }; + + [Theory] + [InlineData(FailureCodes.CredentialDisconnected, "The credential this run used was disconnected", "“GitLab · NACHO.L” was disconnected, so acme/api cannot authenticate with GitLab.")] + [InlineData(FailureCodes.CredentialNeedsReconnect, "GitLab no longer accepts the credential this run used", "refused to renew the sign-in behind “GitLab · NACHO.L”")] + [InlineData(FailureCodes.ProviderUnauthorized, "GitLab rejected the credential for this repository", "GitLab rejected “GitLab · NACHO.L” when it was used for acme/api (HTTP 401).")] + [InlineData(FailureCodes.GitCredentialRejected, "GitLab refused the push", "GitLab refused “GitLab · NACHO.L” when its branch was pushed to acme/api, so nothing was published.")] + [InlineData(FailureCodes.ProviderBaseUrlRedirects, "The provider's address redirects somewhere else", "Requests to http://gitlab.example.com are redirected to https://gitlab.example.com.")] + [InlineData(FailureCodes.OAuthExchangeFailed, "Renewing the sign-in this run used did not work", "GitLab did not renew the sign-in behind “GitLab · NACHO.L” when acme/api needed it")] + [InlineData(WorkflowRunRequestRejectionReasons.CredentialRejected, "GitLab refused the credential this trigger uses", "The author is not in question — the credential is.")] + [InlineData(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, "GitLab would not say who wrote the pull request", "lacks the read_api scope or a role on the project")] + public void Each_code_says_what_is_wrong_and_names_what_it_is_about(string code, string title, string cause) + { + RemedyCopy.Title(code, Facts()).ShouldBe(title); + RemedyCopy.Cause(code, Facts()).ShouldContain(cause); + } + + [Fact] + public void A_redirect_says_what_actually_works_for_the_repair_the_server_will_accept() + { + var edit = RemedyCopy.Cause(FailureCodes.ProviderBaseUrlRedirects, Facts() with { RedirectFix = RedirectRepair.EditAddress }); + var disconnect = RemedyCopy.Cause(FailureCodes.ProviderBaseUrlRedirects, Facts() with { RedirectFix = RedirectRepair.DisconnectFirst, ConnectedCredentials = 2 }); + var hostSide = RemedyCopy.Cause(FailureCodes.ProviderBaseUrlRedirects, Facts() with { RedirectFix = RedirectRepair.HostSide, RedirectsTo = "http://gitlab.example.com" }); + + edit.ShouldContain("Change the provider's address to https://gitlab.example.com"); + disconnect.ShouldNotContain("Change the provider's address to", Case.Insensitive); + disconnect.ShouldContain("2 credentials are connected"); + disconnect.ShouldContain("Disconnect them first"); + disconnect.ShouldContain("add https://gitlab.example.com as a provider of its own"); + hostSide.ShouldNotContain("redirected to http://gitlab.example.com", Case.Insensitive, "the same address on both sides is not a redirect to somewhere else"); + hostSide.ShouldContain("remove the redirect on the host"); + hostSide.ShouldNotContain("Change the provider's address", Case.Insensitive); + } + + [Fact] + public void A_provider_that_did_not_answer_a_renewal_does_not_blame_the_credential() + { + var outage = Facts() with { OAuthError = "http_503" }; + var cause = RemedyCopy.Cause(FailureCodes.OAuthExchangeFailed, outage); + + cause.ShouldContain("(it answered http_503)"); + cause.ShouldContain("says nothing about the credential"); + cause.ShouldNotContain("refused to refresh", Case.Insensitive); + cause.ShouldNotContain("Reconnect it and nothing", Case.Insensitive, "a healthy credential is not sent to be repaired; reconnecting is what to do if it keeps failing"); + RemedyCopy.Cause(FailureCodes.OAuthExchangeFailed, Facts()).ShouldNotContain("(it answered", Case.Insensitive, "an answer that was not recorded is not invented"); + } + + [Theory] + // what the lookup found → what the sentence says is wrong, and whether it sends anyone to repair the credential + [InlineData(StandingLookupCauses.AccessRefused, "lacks the read_api scope or a role on the project", true)] + [InlineData(StandingLookupCauses.NoAnswer, "could not be asked", false)] + [InlineData(StandingLookupCauses.NoCredential, "no credential is linked", false)] + [InlineData(null, "can also be a timeout or an error", true)] // a row written before the cause was kept: both are possible, so both are said + public void A_standing_lookup_that_failed_says_what_failed_not_always_that_the_credential_needs_access(string? lookup, string says, bool mentionsReconnecting) + { + var facts = Facts() with { Lookup = lookup, ProviderStatus = "503" }; + var title = RemedyCopy.Title(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, facts); + var cause = RemedyCopy.Cause(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, facts); + + (title + " " + cause).ShouldContain(says, Case.Insensitive); + (cause.Contains("reconnect", StringComparison.OrdinalIgnoreCase) || cause.Contains("replace", StringComparison.OrdinalIgnoreCase)).ShouldBe(mentionsReconnecting, cause); + cause.ShouldContain("Anyone is not the fix", customMessage: "loosening the trigger never fixes a lookup that failed"); + } + + [Fact] + public void A_lookup_that_got_no_answer_says_what_the_provider_answered_when_it_answered_at_all() + { + RemedyCopy.Cause(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, Facts() with { Lookup = StandingLookupCauses.NoAnswer, ProviderStatus = "429" }).ShouldContain("(HTTP 429)"); + RemedyCopy.Cause(WorkflowRunRequestRejectionReasons.AuthorStandingUnknown, Facts() with { Lookup = StandingLookupCauses.NoAnswer, ProviderStatus = null }).ShouldNotContain("HTTP"); + } + + [Fact] + public void A_retired_credential_says_it_cannot_be_reconnected() + { + var facts = Facts() with { CredentialRetired = true }; + + RemedyCopy.Title(FailureCodes.CredentialDisconnected, facts).ShouldBe("The credential this run used was retired"); + RemedyCopy.Cause(FailureCodes.CredentialDisconnected, facts).ShouldContain("cannot be reconnected"); + } + + [Fact] + public void A_pasted_token_says_token_and_a_sign_in_says_sign_in() + { + RemedyCopy.Cause(FailureCodes.CredentialNeedsReconnect, Facts() with { CredentialIsStaticToken = true }).ShouldContain("rejected the token behind"); + RemedyCopy.Cause(FailureCodes.CredentialNeedsReconnect, Facts()).ShouldContain("renew the sign-in behind"); + } + + [Fact] + public void What_is_not_known_is_left_out_not_invented() + { + var bare = new RemedyFacts(); + + RemedyCopy.Cause(FailureCodes.CredentialDisconnected, bare).ShouldBe("The credential was disconnected, so the repository cannot authenticate with the provider. Reconnect it to put it back, or link the repository to a credential that works."); + RemedyCopy.Title(FailureCodes.GitCredentialRejected, bare).ShouldBe("The provider refused the push"); + } + + [Fact] + public void A_fixed_connection_says_so_and_what_to_do_next() + { + var resolved = Facts() with { Resolved = true }; + + RemedyCopy.Title(FailureCodes.CredentialDisconnected, resolved).ShouldBe("This connection works again"); + RemedyCopy.Cause(FailureCodes.CredentialDisconnected, resolved).ShouldBe("“GitLab · NACHO.L” works again, so acme/api can authenticate. Run it again to pick up where this stopped."); + RemedyCopy.Cause(FailureCodes.ProviderBaseUrlRedirects, resolved).ShouldBe("The provider's address no longer redirects. Run it again to pick up where this stopped."); + RemedyCopy.Cause(WorkflowRunRequestRejectionReasons.CredentialRejected, resolved).ShouldContain("the next pull request event on acme/api starts its run"); + } + + [Fact] + public void Nothing_a_remedy_says_carries_a_token() + { + foreach (var code in FailureCodes.ProviderConnection) + { + RemedyCopy.Cause(code, Facts()).ShouldNotContain("glpat", Case.Insensitive); + RemedyCopy.Title(code, Facts()).ShouldNotBeNullOrWhiteSpace(); + } + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyResolutionTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyResolutionTests.cs new file mode 100644 index 000000000..13a91afe0 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Credentials/Remedy/RemedyResolutionTests.cs @@ -0,0 +1,136 @@ +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using Shouldly; + +namespace CodeSpace.UnitTests.Credentials.Remedy; + +/// +/// When a remedy flips from "repair this" to "run it again". A fix that CodeSpace can see (a dead credential coming back, an +/// address that stopped redirecting) is read off what is recorded; one it cannot (a provider's 401 on a row that looked fine) +/// is only believed once something validated the credential since. +/// +[Trait("Category", "Unit")] +public class RemedyResolutionTests +{ + private static readonly DateTimeOffset Now = new(2030, 6, 1, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset FailedAt = Now.AddHours(-1); + + private static RemedyResolution.CredentialState State(CredentialStatus status = CredentialStatus.Active, AuthType type = AuthType.OAuth, DateTimeOffset? validated = null, DateTimeOffset? expires = null, bool retired = false, Guid? id = null) => + new(id ?? Guid.NewGuid(), status, type, expires, validated, retired); + + [Theory] + [InlineData(CredentialStatus.Active, false, true)] + [InlineData(CredentialStatus.Error, false, false)] + [InlineData(CredentialStatus.Revoked, false, false)] + [InlineData(CredentialStatus.Expired, false, false)] + [InlineData(CredentialStatus.Active, true, false)] // retired: soft-deleted, never live again + public void A_credential_is_live_when_it_is_in_service_and_not_marked_dead(CredentialStatus status, bool retired, bool live) + { + RemedyResolution.IsLive(State(status, retired: retired), Now).ShouldBe(live); + } + + [Fact] + public void A_static_token_past_its_recorded_expiry_is_not_live() + { + RemedyResolution.IsLive(State(type: AuthType.Pat, expires: Now.AddMinutes(-1)), Now).ShouldBeFalse(); + RemedyResolution.IsLive(State(type: AuthType.Pat, expires: Now.AddDays(5)), Now).ShouldBeTrue("expiring soon is still live"); + } + + [Theory] + [InlineData(FailureCodes.CredentialDisconnected)] + [InlineData(FailureCodes.CredentialNeedsReconnect)] + public void A_credential_that_was_dead_and_is_live_again_is_fixed_whatever_the_dates(string code) + { + RemedyResolution.FailingCredentialIsFixed(State(validated: null), code, FailedAt, Now).ShouldBeTrue(); + } + + [Theory] + [InlineData(FailureCodes.ProviderUnauthorized)] + [InlineData(FailureCodes.GitCredentialRejected)] + [InlineData(FailureCodes.OAuthExchangeFailed)] + public void A_credential_a_401_blamed_is_fixed_only_once_something_validated_it_since(string code) + { + RemedyResolution.FailingCredentialIsFixed(State(validated: FailedAt.AddMinutes(-5)), code, FailedAt, Now).ShouldBeFalse("it looked fine before the failure too, so a live row proves nothing"); + RemedyResolution.FailingCredentialIsFixed(State(validated: null), code, FailedAt, Now).ShouldBeFalse(); + RemedyResolution.FailingCredentialIsFixed(State(validated: FailedAt.AddMinutes(5)), code, FailedAt, Now).ShouldBeTrue("a reconnect, a replaced token or a passing probe since"); + } + + [Fact] + public void A_dead_credential_is_never_fixed() + { + RemedyResolution.FailingCredentialIsFixed(State(CredentialStatus.Error, validated: Now), FailureCodes.ProviderUnauthorized, FailedAt, Now).ShouldBeFalse(); + } + + [Fact] + public void Only_a_fix_that_shows_in_the_record_is_observable() + { + RemedyResolution.FixIsObservable(FailureCodes.CredentialDisconnected).ShouldBeTrue(); + RemedyResolution.FixIsObservable(FailureCodes.CredentialNeedsReconnect).ShouldBeTrue(); + RemedyResolution.FixIsObservable(FailureCodes.ProviderBaseUrlRedirects).ShouldBeTrue(); + RemedyResolution.FixIsObservable(FailureCodes.ProviderUnauthorized).ShouldBeFalse(); + RemedyResolution.FixIsObservable(FailureCodes.GitCredentialRejected).ShouldBeFalse(); + } + + [Fact] + public void A_repository_re_linked_to_a_different_live_credential_works() + { + var failing = Guid.NewGuid(); + + RemedyResolution.RepositoryWorks(State(), failing, FailureCodes.CredentialDisconnected, FailedAt, Now).ShouldBeTrue(); + RemedyResolution.RepositoryWorks(State(CredentialStatus.Revoked), failing, FailureCodes.CredentialDisconnected, FailedAt, Now).ShouldBeFalse("moved onto another dead credential"); + RemedyResolution.RepositoryWorks(null, failing, FailureCodes.CredentialDisconnected, FailedAt, Now).ShouldBeFalse("bound to nothing, or to a credential that is gone"); + } + + [Fact] + public void A_repository_still_on_the_failing_credential_works_only_when_that_credential_is_fixed() + { + var failing = Guid.NewGuid(); + + RemedyResolution.RepositoryWorks(State(id: failing, status: CredentialStatus.Error), failing, FailureCodes.CredentialNeedsReconnect, FailedAt, Now).ShouldBeFalse(); + RemedyResolution.RepositoryWorks(State(id: failing), failing, FailureCodes.CredentialNeedsReconnect, FailedAt, Now).ShouldBeTrue("reconnected in place: same row, live again"); + RemedyResolution.RepositoryWorks(State(id: failing), failing, FailureCodes.ProviderUnauthorized, FailedAt, Now).ShouldBeFalse("a 401 on a live-looking row needs a validation after it"); + } + + [Theory] + [InlineData("http://gitlab.example.com", "http://gitlab.example.com", false)] + [InlineData("http://gitlab.example.com", "https://gitlab.example.com", true)] + [InlineData("http://gitlab.example.com", "http://gitlab.example.com/", false)] + [InlineData("http://gitlab.example.com", "HTTP://GITLAB.EXAMPLE.COM/gitlab", false)] + [InlineData("https://gitlab.example.com:8443", "https://gitlab.example.com", true)] + [InlineData("https://old.example.com", "https://new.example.com", true)] + [InlineData(null, "https://new.example.com", false)] + public void A_redirect_is_resolved_once_the_base_url_moves_off_the_redirecting_origin(string? requested, string currentBaseUrl, bool resolved) + { + RemedyResolution.RedirectIsResolved(requested, currentBaseUrl).ShouldBe(resolved); + } + + [Theory] + // requested origin, where it points, the stored base URL, the stored API URL, how many credentials are connected → the repair that will be ACCEPTED + [InlineData("http://gitlab.example.com", "https://gitlab.example.com", "http://gitlab.example.com", null, 3, RedirectRepair.EditAddress)] // the one edit allowed past connected tokens: http to https on the same host + [InlineData("http://gitlab.example.com", "https://gitlab.example.com", "http://gitlab.example.com/gitlab", null, 3, RedirectRepair.EditAddress)] + [InlineData("http://old.example.com", "https://new.example.com", "http://old.example.com", null, 0, RedirectRepair.EditAddress)] // nothing connected, so a connection moves freely + [InlineData("http://old.example.com", "https://new.example.com", "http://old.example.com", null, 1, RedirectRepair.DisconnectFirst)] // another host: its tokens would follow it + [InlineData("http://gitlab.example.com", "http://gitlab.example.com:8080", "http://gitlab.example.com", null, 2, RedirectRepair.DisconnectFirst)] // another port + [InlineData("https://gitlab.example.com", "http://gitlab.example.com", "https://gitlab.example.com", null, 2, RedirectRepair.DisconnectFirst)] // a downgrade is not an upgrade + [InlineData("http://gitlab.example.com", "https://gitlab.example.com", "http://gitlab.example.com", "https://api.example.com", 2, RedirectRepair.EditAddress)] // the API address is its own and does not move + [InlineData("http://gitlab.example.com", "http://gitlab.example.com", "http://gitlab.example.com", null, 0, RedirectRepair.HostSide)] // same origin: a redirect inside it that was not followed; no address of ours to correct + [InlineData("http://gitlab.example.com", "HTTP://GITLAB.EXAMPLE.COM", "http://gitlab.example.com", null, 2, RedirectRepair.HostSide)] + [InlineData("http://gitlab.example.com", null, "http://gitlab.example.com", null, 0, RedirectRepair.HostSide)] // it named nowhere to point + public void Only_a_repair_the_server_will_accept_is_offered_for_a_redirect(string? requested, string? to, string baseUrl, string? apiUrl, int connected, RedirectRepair expected) + { + RemedyResolution.RepairRedirect(requested, to, baseUrl, apiUrl, connected).ShouldBe(expected); + } + + [Theory] + // the server's own rule (ProviderInstanceService.EndpointMoves) is what decides — this pins that the remedy and the endpoint cannot disagree + [InlineData("http://old.example.com", "https://new.example.com", 1)] + [InlineData("http://gitlab.example.com", "https://gitlab.example.com", 1)] + [InlineData("http://gitlab.example.com", "http://gitlab.example.com:81", 1)] + public void The_repair_agrees_with_what_editing_the_address_would_do_while_credentials_are_connected(string baseUrl, string to, int connected) + { + var edit = CodeSpace.Core.Services.ProviderInstances.ProviderInstanceService.EndpointMoves(baseUrl, null, to, null); + + (RemedyResolution.RepairRedirect(baseUrl, to, baseUrl, null, connected) == RedirectRepair.DisconnectFirst).ShouldBe(edit); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Failures/PersistedFailureJsonTests.cs b/backend/tests/CodeSpace.UnitTests/Failures/PersistedFailureJsonTests.cs new file mode 100644 index 000000000..94d738846 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Failures/PersistedFailureJsonTests.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.Failures; +using CodeSpace.Messages.Failures; +using Shouldly; + +namespace CodeSpace.UnitTests.Failures; + +/// +/// How a persisted failure sits in a ledger payload and in an agent result: an additive failure object, readable back +/// exactly, and absent — never an error — on every row written before failures were kept. +/// +[Trait("Category", "Unit")] +public class PersistedFailureJsonTests +{ + private static readonly Guid Credential = Guid.NewGuid(); + private static readonly Guid Repository = Guid.NewGuid(); + + [Fact] + public void A_failure_survives_the_trip_through_a_record_payload() + { + var failure = new PersistedFailure { Code = FailureCodes.CredentialDisconnected, Details = new Dictionary { ["credentialId"] = Credential.ToString() }, RepositoryId = Repository }; + var payload = JsonSerializer.Serialize(new { error = "The credential was disconnected", outputs = new { }, duration_ms = 5L, failure = PersistedFailureJson.ToElement(failure) }); + + var read = PersistedFailureJson.Read(payload); + + read.ShouldNotBeNull(); + read.Code.ShouldBe(FailureCodes.CredentialDisconnected); + read.DetailGuid("credentialId").ShouldBe(Credential); + read.RepositoryId.ShouldBe(Repository); + } + + [Fact] + public void The_stored_shape_is_the_one_a_client_and_an_agent_result_both_read() + { + var json = PersistedFailureJson.ToElement(new PersistedFailure { Code = FailureCodes.ProviderUnauthorized, RepositoryId = Repository }).GetRawText(); + + json.ShouldBe($$"""{"code":"provider_unauthorized","repositoryId":"{{Repository}}"}""", "camelCase, and a detail that is absent is not written as null"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("{}")] + [InlineData("""{"error":"Node 'x' failed.","outputs":{},"duration_ms":3}""")] // a record written before failures were kept + [InlineData("""{"error":"x","failure":null}""")] + [InlineData("""{"error":"x","failure":"a string"}""")] + [InlineData("""{"error":"x","failure":{"details":{}}}""")] // no code + [InlineData("""{"error":"x","failure":{"code":""}}""")] + [InlineData("not json at all")] + [InlineData("[1,2,3]")] + public void A_record_without_a_readable_failure_reads_as_none_and_never_throws(string? payload) + { + PersistedFailureJson.Read(payload).ShouldBeNull(); + } + + [Fact] + public void Details_are_kept_as_the_text_they_were_thrown_as() + { + var details = PersistedFailure.DetailsAsText(new Dictionary { ["providerStatus"] = 401, ["credentialId"] = Credential, ["ignored"] = null }); + + details.ShouldNotBeNull(); + details["providerStatus"].ShouldBe("401"); + details["credentialId"].ShouldBe(Credential.ToString()); + details.ContainsKey("ignored").ShouldBeFalse(); + PersistedFailure.DetailsAsText(new Dictionary { ["only"] = null }).ShouldBeNull("nothing said is nothing kept"); + PersistedFailure.DetailsAsText(null).ShouldBeNull(); + } + + [Fact] + public void A_blank_or_malformed_detail_reads_as_absent() + { + var failure = new PersistedFailure { Code = "x", Details = new Dictionary { ["blank"] = " ", ["guid"] = "nope", ["empty"] = Guid.Empty.ToString() } }; + + failure.Detail("blank").ShouldBeNull(); + failure.Detail("missing").ShouldBeNull(); + failure.DetailGuid("guid").ShouldBeNull(); + failure.DetailGuid("empty").ShouldBeNull(); + } + + [Fact] + public void An_agent_result_names_its_failure_or_a_refused_push() + { + var own = $$$"""{"status":"Failed","exitReason":"credential_disconnected","failure":{"code":"credential_disconnected","details":{"credentialId":"{{{Credential}}}"},"repositoryId":"{{{Repository}}}"}}"""; + var refusedPush = """{"status":"Succeeded","exitReason":"completed","publishError":"refused","publishFailureCode":"git_credential_rejected"}"""; + var refusedRepo = $$$"""{"status":"Succeeded","exitReason":"completed","repositoryResults":[{"alias":"a","publishFailureCode":null},{"alias":"b","repositoryId":"{{{Repository}}}","publishFailureCode":"git_credential_rejected"}]}"""; + var clean = """{"status":"Succeeded","exitReason":"completed","repositoryResults":[{"alias":"a"}]}"""; + + RunFailureReader.ReadAgentResult(own)!.DetailGuid("credentialId").ShouldBe(Credential); + RunFailureReader.ReadAgentResult(refusedPush)!.Code.ShouldBe(FailureCodes.GitCredentialRejected); + RunFailureReader.ReadAgentResult(refusedRepo)!.RepositoryId.ShouldBe(Repository, "the first repository whose push was refused"); + RunFailureReader.ReadAgentResult(clean).ShouldBeNull(); + RunFailureReader.ReadAgentResult("not json").ShouldBeNull(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Failures/ProviderFailureCaptureTests.cs b/backend/tests/CodeSpace.UnitTests/Failures/ProviderFailureCaptureTests.cs new file mode 100644 index 000000000..cee64f217 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Failures/ProviderFailureCaptureTests.cs @@ -0,0 +1,135 @@ +using CodeSpace.Core.Failures; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Core.Services.OAuth; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using CodeSpace.Messages.Failures; +using Shouldly; + +namespace CodeSpace.UnitTests.Failures; + +/// +/// Which thrown exceptions become a persisted provider failure, and what they carry. Only a declared failure that a person can +/// repair outside the run is written down; everything else stays the sentence it was. +/// +[Trait("Category", "Unit")] +public class ProviderFailureCaptureTests +{ + private static readonly Guid Credential = Guid.NewGuid(); + private static readonly Guid Repository = Guid.NewGuid(); + + [Fact] + public void A_disconnected_credential_is_captured_with_the_credential_it_names() + { + var failure = ProviderFailureCapture.From(new CredentialDisconnectedException(Credential, "GitLab · nacho"), Repository); + + failure.ShouldNotBeNull(); + failure.Code.ShouldBe(FailureCodes.CredentialDisconnected); + failure.DetailGuid("credentialId").ShouldBe(Credential); + failure.RepositoryId.ShouldBe(Repository, "the repository the failing step was acting on"); + } + + [Fact] + public void A_credential_its_provider_refused_is_captured_with_its_name_and_id() + { + var failure = ProviderFailureCapture.From(new CredentialNeedsReconnectException(Credential, "GitLab · nacho", "invalid_grant"), repositoryId: null); + + failure!.Code.ShouldBe(FailureCodes.CredentialNeedsReconnect); + failure.DetailGuid("credentialId").ShouldBe(Credential); + failure.Detail("credentialName").ShouldBe("GitLab · nacho"); + failure.RepositoryId.ShouldBeNull(); + } + + [Fact] + public void A_provider_401_is_captured_without_a_credential_and_traceable_through_the_repository() + { + var failure = ProviderFailureCapture.From(new ProviderApiException(ProviderKind.GitLab, 401, "ListPullRequestsAsync", "401 Unauthorized", new Exception("inner")), Repository); + + failure!.Code.ShouldBe(FailureCodes.ProviderUnauthorized); + failure.Detail("providerStatus").ShouldBe("401"); + failure.Detail("provider").ShouldBe("GitLab"); + failure.DetailGuid("credentialId").ShouldBeNull("a provider's answer names no credential; the repository does"); + failure.RepositoryId.ShouldBe(Repository); + } + + [Fact] + public void A_provider_answer_that_is_not_a_credential_refusal_is_not_captured() + { + ProviderFailureCapture.From(new ProviderApiException(ProviderKind.GitLab, 500, "ListPullRequestsAsync", "boom", new Exception("inner")), Repository).ShouldBeNull("a 5xx is the provider's outage, not a connection a person repairs"); + } + + [Fact] + public void A_redirecting_provider_is_captured_with_where_it_pointed() + { + var failure = ProviderFailureCapture.From(new ProviderBaseUrlRedirectsException(ProviderKind.GitLab, new Uri("http://gitlab.example.com/api/v4/user"), new Uri("https://gitlab.example.com/api/v4/user")), repositoryId: null); + + failure!.Code.ShouldBe(FailureCodes.ProviderBaseUrlRedirects); + failure.Detail("requestedOrigin").ShouldBe("http://gitlab.example.com"); + failure.Detail("finalOrigin").ShouldBe("https://gitlab.example.com"); + failure.Detail("finalUrl").ShouldBe("https://gitlab.example.com/api/v4/user"); + } + + [Fact] + public void A_rejected_push_names_its_repository_itself_and_that_wins_over_the_step_it_was_raised_in() + { + var own = Guid.NewGuid(); + var failure = ProviderFailureCapture.From(new GitCredentialRejectedException("the remote refused it", own), Repository); + + failure!.Code.ShouldBe(FailureCodes.GitCredentialRejected); + failure.RepositoryId.ShouldBe(own, "the more specific fact"); + } + + [Fact] + public void A_rejected_push_caused_by_a_dead_credential_carries_that_credential() + { + var inner = new CredentialDisconnectedException(Credential, "GitLab · nacho"); + + ProviderFailureCapture.From(new GitCredentialRejectedException("disconnected while the run worked", inner, Repository), null)!.DetailGuid("credentialId").ShouldBe(Credential); + } + + [Fact] + public void An_oauth_refresh_refusal_names_the_credential_whose_refresh_it_answered() + { + var refused = new OAuthExchangeException("http_503", "unavailable", null).ForCredential(Credential); + var failure = ProviderFailureCapture.From(refused, null); + + failure!.Code.ShouldBe(FailureCodes.OAuthExchangeFailed); + failure.DetailGuid("credentialId").ShouldBe(Credential); + failure.Detail("oauthError").ShouldBe("http_503", "what the provider answered — an error code, never the description or the body — so the sentence can say it was an outage and not a refusal"); + } + + [Fact] + public void An_exchange_before_any_credential_existed_is_captured_with_no_credential() + { + ProviderFailureCapture.From(new OAuthExchangeException("invalid_grant", null, null), null)!.Details.ShouldBeNull(); + } + + [Fact] + public void A_failure_wrapped_in_another_exception_is_still_found() + { + var wrapped = new InvalidOperationException("node failed", new AggregateException(new Exception("x"), new CredentialDisconnectedException(Credential, "n"))); + + ProviderFailureCapture.From(wrapped, null)!.Code.ShouldBe(FailureCodes.CredentialDisconnected); + } + + [Theory] + [InlineData(typeof(InvalidOperationException))] + [InlineData(typeof(KeyNotFoundException))] + [InlineData(typeof(TimeoutException))] + public void An_exception_that_declares_nothing_about_a_provider_is_not_captured(Type type) + { + ProviderFailureCapture.From((Exception)Activator.CreateInstance(type, "x")!, Repository).ShouldBeNull(); + } + + [Fact] + public void A_declared_failure_that_is_not_a_provider_connection_is_left_as_the_sentence_it_was() + { + ProviderFailureCapture.From(new ActorIdentityRequiredException(ProviderKind.GitLab, Guid.NewGuid()), Repository).ShouldBeNull("it has its own surface — the identity gate"); + } + + [Fact] + public void Nothing_thrown_is_nothing_captured() + { + ProviderFailureCapture.From(null, Repository).ShouldBeNull(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomNarrativeTests.cs b/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomNarrativeTests.cs index bbea39a07..3a2938e56 100644 --- a/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomNarrativeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Sessions/Room/RoomNarrativeTests.cs @@ -14,6 +14,7 @@ using CodeSpace.Messages.Plans; using CodeSpace.Messages.Tasks.Phases; using CodeSpace.UnitTests.Tasks.Phases; +using CodeSpace.Messages.Failures; using Shouldly; namespace CodeSpace.UnitTests.Sessions.Room; @@ -238,6 +239,37 @@ public void A_lost_model_credential_lease_is_NOT_dressed_up_as_a_rejected_creden diag.Text.ShouldContain("Retry", Case.Insensitive, "and what to do about it"); } + [Theory] + [InlineData(FailureCodes.CredentialDisconnected, "The credential 'GitLab · nacho' was disconnected, so nothing bound through it can authenticate.")] + [InlineData(FailureCodes.CredentialNeedsReconnect, "The credential 'GitLab · nacho' cannot authenticate: its provider refused it (invalid_grant).")] + [InlineData(FailureCodes.ProviderUnauthorized, "GitLab returned HTTP 401 for ListPullRequestsAsync: 401 Unauthorized")] + [InlineData(FailureCodes.GitCredentialRejected, "The remote refused the git credential, so the branch was not published.")] + [InlineData(FailureCodes.ProviderBaseUrlRedirects, "GitLab answered a request with a redirect to another origin. The credential was not sent.")] + [InlineData(FailureCodes.OAuthExchangeFailed, "OAuth exchange rejected: http_503 (credential x)")] + public void A_provider_connection_failure_is_never_told_as_a_rejected_model_credential(string code, string raw) + { + // Every one of these sentences contains "credential", "unauthorized" or "401" — exactly what the auth scan reads as a rejected + // MODEL key — so without the recorded failure the Room sent a person to the model credentials to fix a git connection. + var facts = new RoomTurnFacts { RawError = raw, Failure = new PersistedFailure { Code = code } }; + + var diag = Build(Array.Empty(), WorkflowRunStatus.Failure, facts: facts).Blocks.OfType().ShouldHaveSingleItem(); + + diag.Title.ShouldNotBe("Authentication failed"); + diag.Title.ShouldNotBeNullOrWhiteSpace("the failure is named in plain language"); + diag.Actions.ShouldNotContain(a => a.Kind == RoomActionKind.FixCredentials, "the model credentials are not what is broken"); + diag.FailureCode.ShouldBe(code, "the page asks for the live remedy by this code"); + diag.Text.ShouldBe(raw, "the card's words are the failure's own, not a rewrite"); + } + + [Fact] + public void A_failure_that_is_not_a_provider_connection_carries_no_failure_code() + { + var diag = Build(Array.Empty(), WorkflowRunStatus.Failure, facts: new RoomTurnFacts { RawError = "OpenAI API error: 401 Unauthorized" }).Blocks.OfType().ShouldHaveSingleItem(); + + diag.FailureCode.ShouldBeNull(); + diag.Title.ShouldBe("Authentication failed", "a rejected MODEL key still gets its own fix"); + } + [Fact] public void A_size_refusal_whose_count_contains_401_is_not_dressed_up_as_a_rejected_credential() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/WorkflowRunRequestRejectionReasonsTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/WorkflowRunRequestRejectionReasonsTests.cs index e0371b3fe..d21a3b1ea 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/WorkflowRunRequestRejectionReasonsTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/WorkflowRunRequestRejectionReasonsTests.cs @@ -34,4 +34,16 @@ public void Rejection_reason_string_form_is_pinned(string expected, string const $"WorkflowRunRequestRejectionReasons.{constantName} MUST be wire string '{expected}' — operators filter " + "audit views on these literals. Rename → migration to update every existing row, OR don't rename."); } + + [Theory] + [InlineData("lookup", nameof(StandingLookupCauses.DetailKey))] + [InlineData("no_credential", nameof(StandingLookupCauses.NoCredential))] + [InlineData("access_refused", nameof(StandingLookupCauses.AccessRefused))] + [InlineData("no_answer", nameof(StandingLookupCauses.NoAnswer))] + public void Standing_lookup_cause_strings_are_pinned(string expected, string constantName) + { + // They are written into workflow_run_request.request_metadata_json on every refusal that could not read a standing, and read back + // by the remedy that words it. A rename would turn every stored row into "cause unknown" — which reads as a credential fault. + (typeof(StandingLookupCauses).GetField(constantName)!.GetValue(null) as string).ShouldBe(expected, $"StandingLookupCauses.{constantName} MUST stay '{expected}'"); + } } diff --git a/backend/tests/TESTING.md b/backend/tests/TESTING.md index 550a3e82d..968237aa0 100644 --- a/backend/tests/TESTING.md +++ b/backend/tests/TESTING.md @@ -62,6 +62,18 @@ Provider-credential tests share two more pieces, both in `CodeSpace.IntegrationT accepts the connection and never answers, for what a hung GitLab costs the caller. A call to it only returns when the caller gives up, so a test of it runs the call on a thread of its own and bounds the wait, and shortens the sweep's deadlines by registering a `CredentialHealthDeadlines` instead of waiting out the real ones. +- Provider failures that reach a person: a failed run's record keeps the failure as a code and what it names, and the run page answers + what is wrong and what the caller can do. `ProviderFailureThroughTheEngineFlowTests` drives the real engine against the stub or a + loopback host: a real `git.list_prs` (a dead OAuth grant, a token GitLab revoked, a disconnected credential, a renewal GitLab did not + answer, an address that redirects) and the six git write nodes that word a provider 401 themselves (`git.open_pr`, `git.merge_pr`, + `git.pr_review`, `git.create_issue`, `git.close_issue`, `git.comment_issue`), and asserts what the node's ledger record carries and what + the remedy says. A repair the remedy offers is proved against the real command the button runs: the redirect test sends + `UpdateProviderInstanceCommand` (refused while a token is connected, accepted once it is disconnected) and `InitCredentialOAuthCommand` + is sent as the credential's own Member to show the button's enabled state is the endpoint's answer — never an edit by SQL that + production would refuse. `ProviderRemedyFlowTests` pins the per-role matrix and every other source of a failure (a run that failed on + another node than the one that handled a provider failure has none; a standing lookup that got no answer blames nobody); + `ProviderRemedyHttpE2ETests` pins the wire shape for an Owner, a Member and a Viewer. Those fixtures write the failure with + the real `IRunRecordLogger` from the real exception through `ProviderFailureCapture`, never a hand-typed payload. - `RefreshPodHarness` — pods as real OS processes (`CodeSpace.StorageTestWorker`'s `oauth-refresh-pod` mode) running the production refresher, or the pinned previous build's (`StorageTestWorker/PreviousBuild`), against the fixture's Postgres. One process shares one refresh semaphore, so only separate processes exercise the cross-pod advisory lock. diff --git a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx index c3936f398..1f2386746 100644 --- a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx +++ b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx @@ -9,7 +9,8 @@ import { ReplaceTokenModal } from "@/components/identities/ReplaceTokenModal"; import { useAddGroupAccessToken, useAddProviderInstance, useCredentialCapabilities, useCredentials, useDeleteProviderInstance, useProviderDefaults, useProviderInstances, useRevokeCredential, useUpdateProviderInstance } from "@/hooks/use-credentials"; import { useMe } from "@/hooks/use-me"; import { TeamPermissions, useTeamPermissions } from "@/hooks/use-team-management"; -import { OAuthFlowError, useOAuthFlow } from "@/hooks/use-oauth-flow"; +import { describeOAuthFlowError, OAuthFlowError, useOAuthFlow } from "@/hooks/use-oauth-flow"; +import { credentialHealthWord, isLiveCredential, isStaticToken } from "@/lib/credentialHealth"; import { providerEndpointMoves, providerSecretMustBeReentered, showsMaskedCredentials } from "@/lib/credentialEndpoint"; import { providerSupportsTeamServiceCredential } from "@/lib/teamCredentials"; @@ -31,6 +32,10 @@ import { Ic } from "./icons"; interface ConnectRemoteModalProps { onClose: () => void; + /** Open straight on this provider's edit form — where a redirecting address gets corrected. Waits for the provider list to arrive. */ + editInstanceId?: string; + /** The base URL the edit form starts with instead of the stored one: the address the provider answered on, when it redirected. */ + suggestedBaseUrl?: string; } type Step = "list" | "addProvider" | "editProvider" | "addTeamToken"; @@ -75,41 +80,61 @@ function capabilityLabel(key: string): string { ?? key.replace(/^I/, "").replace(/Capability$/, "").replace(/([a-z])([A-Z])/g, "$1 $2"); } -/** The provider refused the credential, or its token has lapsed: it needs reconnecting (OAuth) or a new token. `status` alone is not enough — a token whose expiry passed reads rejected before anything has marked it. */ -function isRefused(credential: CredentialSummary): boolean { - return credential.status === "Error" || credential.health === "rejected"; -} - -/** What a credential's health says to the person, or nothing when it is fine: a refused one how to come back, a token about to expire how long it has. */ +/** + * What a credential's health says to the person — every state, so no credential reads as healthy by saying nothing: working, + * a token about to expire (and how long it has), one the provider refused (how to come back), one disconnected, one lent by + * someone who left the team. The words come from `credentialHealthWord`, which folds `status` in for a server that sends no `health`. + */ function CredentialHealthChip({ credential }: { credential: CredentialSummary }) { - if (isRefused(credential)) { + const word = credentialHealthWord(credential); + + if (word === "rejected") { return ( - {credential.authType === "OAuth" ? "needs reconnect" : "needs a new token"} + {isStaticToken(credential) ? "needs a new token" : "needs reconnect"} ); } - if (credential.health !== "expiring") return null; + if (word === "disconnected") return disconnected; + if (word === "owner_left") return owner left; - const days = credential.daysToExpiry ?? 0; + if (word === "expiring") { + const days = credential.daysToExpiry ?? 0; - return ( - - {days === 0 ? "expires today" : `expires in ${days} ${days === 1 ? "day" : "days"}`} - - ); + return ( + + {days === 0 ? "expires today" : `expires in ${days} ${days === 1 ? "day" : "days"}`} + + ); + } + + return active; +} + +/** How many repositories still run through a credential — what reconnecting, replacing or disconnecting it puts at stake. Nothing when none do. */ +function BoundRepositories({ credential }: { credential: CredentialSummary }) { + const count = credential.boundRepositoryCount ?? 0; + + return count > 0 ? {count} {count === 1 ? "repository" : "repositories"} bound : null; } /** - * A credential the lists show: a working one, and one the provider has refused — which is the one Reconnect and Replace - * token exist for, so it must stay reachable. A disconnected (Revoked) one is not listed: connecting again is how it comes back. + * Every credential is listed, whatever shape it is in — a refused one and a disconnected one are exactly the ones a person came + * to repair, and hiding them left a dead credential invisible with its repositories still bound to it. (A retired credential is + * not in the API's list at all; what leans on it is reached through its repositories.) Ones that need a person's attention come + * first, so the list opens on what is broken. */ -function isListed(credential: CredentialSummary): boolean { +function needsAttentionFirst(credentials: CredentialSummary[]): CredentialSummary[] { + return [...credentials].sort((a, b) => Number(isLiveCredential(a)) - Number(isLiveCredential(b)) || b.createdDate.localeCompare(a.createdDate)); +} + +/** A credential that is neither refused nor disconnected: the ones that make a provider read "connected" and that "Connect" need not be offered over. */ +function isInService(credential: CredentialSummary): boolean { return credential.status === "Active" || credential.status === "Error"; } -export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { +export function ConnectRemoteModal({ onClose, editInstanceId, suggestedBaseUrl }: ConnectRemoteModalProps) { const [step, setStep] = useState("list"); const [editingInstance, setEditingInstance] = useState(null); const [teamTokenInstance, setTeamTokenInstance] = useState(null); @@ -141,9 +166,7 @@ export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { const myId = me.data?.id; if (!myId) return map; - const mine = (credentials.data ?? []) - .filter(c => c.ownerUserId === myId && isListed(c)) - .sort((a, b) => b.createdDate.localeCompare(a.createdDate)); + const mine = needsAttentionFirst((credentials.data ?? []).filter(c => c.ownerUserId === myId)); for (const c of mine) { const list = map.get(c.providerInstanceId); @@ -155,7 +178,7 @@ export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { // Active team-service credentials (owned by the team, not a person). Shown in the modal's // "Team" tab, grouped under the provider each belongs to — a distinct identity from a personal sign-in. const teamServiceCreds = useMemo( - () => (credentials.data ?? []).filter(c => c.ownership === "TeamService" && isListed(c)), + () => needsAttentionFirst((credentials.data ?? []).filter(c => c.ownership === "TeamService")), [credentials.data], ); @@ -165,6 +188,20 @@ export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { return () => window.removeEventListener("keydown", handler); }, [onClose]); + // Opened to correct one provider's address: go to its edit form as soon as the list says which provider that is. Once — backing + // out of the form lands on the list, as it does everywhere, and must not be sent straight back. + const [pendingEditId, setPendingEditId] = useState(editInstanceId); + useEffect(() => { + if (!pendingEditId) return; + + const instance = allProviders.find(i => i.id === pendingEditId); + if (!instance) return; + + setEditingInstance(instance); + setStep("editProvider"); + setPendingEditId(undefined); + }, [pendingEditId, allProviders]); + // `reauthorizing` names the credential a Reconnect brings back to life IN PLACE: same credential, same id, so the // repositories bound through it keep working. Without it this is a plain connect, which creates a new credential. const connect = async (instance: ProviderInstanceSummary, reauthorizing?: CredentialSummary) => { @@ -177,7 +214,7 @@ export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { // Hook invalidates ['credentials']; row re-renders with "Connected" automatically. } catch (err) { const message = - err instanceof OAuthFlowError ? mapFlowErrorMessage(err) + err instanceof OAuthFlowError ? describeOAuthFlowError(err) : err instanceof ApiError ? err.message : err instanceof Error ? err.message : "Authorization failed."; @@ -204,8 +241,9 @@ export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { if (moved > 0 && usage.willMoveToDisplayName) { // Your only other connection on this provider: the repositories carry on through it, never stop working. usageMsg = `This credential will be revoked. ${moved} repositor${moved === 1 ? "y" : "ies"} bound through it will move to your other connection, ${usage.willMoveToDisplayName}, and keep working.`; - } else if (usage.activeRepositoryCount > 0) { - const n = usage.activeRepositoryCount; + } else if (usage.activeRepositoryCount > 0 || (credential.boundRepositoryCount ?? 0) > 0) { + // Every repository still bound, not only the ones still working: those already waiting on a credential stay waiting. + const n = Math.max(usage.activeRepositoryCount, credential.boundRepositoryCount ?? 0); usageMsg = `This credential will be revoked. ${n} repositor${n === 1 ? "y" : "ies"} bound through this credential will be marked as needing a new credential (event webhooks keep working — you can re-link or unbind later).`; } } catch { @@ -284,6 +322,7 @@ export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { {step === "editProvider" && editingInstance && ( { setStep("list"); setEditingInstance(null); }} onClose={onClose} onSaved={() => { setStep("list"); setEditingInstance(null); }} @@ -477,9 +516,13 @@ interface ProviderRowProps { } function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, errorMsg, onConnect, onReconnect, onReplaceToken, onConnectPat, onDisconnect, onEdit }: ProviderRowProps) { - const connected = myCreds.length > 0; - const hasOAuth = myCreds.some(c => c.authType === "OAuth"); - const hasPat = myCreds.some(c => c.authType === "Pat"); + // "Connected" is about a credential that works; a disconnected one is listed (so it can be reconnected) but is not a connection. + const connected = myCreds.some(isLiveCredential); + // A refused credential is brought back in place (Reconnect, Replace token), so it counts as the connection of its kind — a second + // "Connect" beside it would mint another. A disconnected one does not: connecting again is one way back, Reconnect on it another. + const hasOAuth = myCreds.some(c => c.authType === "OAuth" && isInService(c)); + const hasPat = myCreds.some(c => c.authType === "Pat" && isInService(c)); + const listed = myCreds.length > 0; // Connect affordances for the methods NOT yet linked. OAuth needs a configured app; a personal // token is always available; a provider with no OAuth app instead offers "Configure OAuth". @@ -513,11 +556,11 @@ function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, error {instance.baseUrl} - {onEdit && } + {onEdit && } - {/* What you HAVE — one uniform row per linked credential, labelled by method, each independently revocable. */} - {connected && ( + {/* What you HAVE — one uniform row per credential, labelled by method, each independently repairable and revocable. */} + {listed && (
{myCreds.map(c => (
@@ -527,6 +570,7 @@ function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, error connected {formatRelative(c.createdDate)} + {/* Sign in again to the SAME credential (an OAuth sign-in), or paste a new token into it (a token): either way it keeps its id, so the repositories bound through it keep working. */} {c.authType === "OAuth" && instance.oauthEnabled && onReconnect && ( @@ -537,9 +581,11 @@ function ProviderRow({ instance, theme, myCreds, isConnecting, revokingId, error {c.authType !== "OAuth" && ( )} - + {credentialHealthWord(c) !== "disconnected" && ( + + )}
@@ -612,10 +658,11 @@ function TeamProviderGroup({ instance, theme, creds, revokingId, onAdd, onRevoke {c.displayName} + {onReplaceToken && ( )} - {onRevoke && ( + {onRevoke && credentialHealthWord(c) !== "disconnected" && ( @@ -1034,6 +1081,8 @@ function AddProviderStep({ onBack, onClose, onCreated }: AddProviderStepProps) { interface EditProviderStepProps { instance: ProviderInstanceSummary; + /** The base URL the form starts with, when it should not be the stored one. */ + initialBaseUrl?: string; onBack: () => void; onClose: () => void; onSaved: () => void; @@ -1051,11 +1100,11 @@ interface EditProviderStepProps { * secret again (the new host's app), and the server refuses the move while tokens are still * connected through this provider — they are never sent to a host they weren't connected for. */ -function EditProviderStep({ instance, onBack, onClose, onSaved }: EditProviderStepProps) { +function EditProviderStep({ instance, initialBaseUrl, onBack, onClose, onSaved }: EditProviderStepProps) { const update = useUpdateProviderInstance(); const [displayName, setDisplayName] = useState(instance.displayName); - const [baseUrl, setBaseUrl] = useState(instance.baseUrl); + const [baseUrl, setBaseUrl] = useState(initialBaseUrl ?? instance.baseUrl); const [clientId, setClientId] = useState(""); // intentionally blank — we don't fetch the existing value (kept simple; you can paste to overwrite) const [clientSecret, setClientSecret] = useState(""); @@ -1354,13 +1403,3 @@ function purposeFor(provider: ProviderKind, oauthEnabled: boolean): string { return `Sign in with your ${provider} account to bring your repos in.`; } -function mapFlowErrorMessage(err: OAuthFlowError) { - switch (err.code) { - case "popup_blocked": return "Your browser blocked the OAuth popup. Allow popups for this site and try again."; - case "cancelled": return "You closed the authorization window. No connection was made."; - case "init_failed": return `Couldn't start the OAuth flow: ${err.message}`; - case "provider_error": return err.providerError ? `Provider rejected: ${err.providerError}` : err.message; - case "timeout": return "The authorization took too long. Try again."; - default: return err.message; - } -} diff --git a/frontend/src/_imported/ai-code-space/content.tsx b/frontend/src/_imported/ai-code-space/content.tsx index aff7178ac..223b195ed 100644 --- a/frontend/src/_imported/ai-code-space/content.tsx +++ b/frontend/src/_imported/ai-code-space/content.tsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useMemo, useState } from "react"; import { ApiError } from "@/api/request"; import type { CredentialSummary, ProviderInstanceSummary, ProviderKind, RepositorySummary } from "@/api/types"; -import { useAlert, useConfirm } from "@/components/dialog"; -import { useCredentials, useProviderInstances } from "@/hooks/use-credentials"; -import { useRelinkRepositoryCredential, useRepositories, useUnbindRepository } from "@/hooks/use-repositories"; +import { useConfirm } from "@/components/dialog"; +import { ConnectionIssueChip, RepositoryConnectionFix } from "@/components/remedy/RepositoryConnectionIssue"; +import { useRepositoryConnectionIssues } from "@/hooks/use-repository-connection-issues"; +import { useProviderInstances } from "@/hooks/use-credentials"; +import { useRepositories, useUnbindRepository } from "@/hooks/use-repositories"; import { TeamPermissions, useTeamPermissions } from "@/hooks/use-team-management"; import { credentialOwnershipLabel } from "@/lib/credentialOrdering"; @@ -89,16 +90,15 @@ export function RepositoryListPage({ tab, query, onTabChange, onQueryChange, onO const repositories = useRepositories(); const instances = useProviderInstances(); - const credentials = useCredentials(); + const connection = useRepositoryConnectionIssues(); const unbind = useUnbindRepository(); const confirm = useConfirm(); - // Adding (a bind), unbinding and re-linking a repository all take repos.manage on the server. Without it the - // controls are absent rather than present-and-refused; the Error badge still says the repository needs a credential. + // Adding (a bind) and unbinding a repository take repos.manage on the server. Without it the controls are absent rather than + // present-and-refused; the connection chip still says what is wrong, and the fix beside it offers only what the caller may do. const mayManageRepos = useTeamPermissions().can(TeamPermissions.ReposManage); const instanceById = useMemo(() => new Map((instances.data ?? []).map(i => [i.id, i])), [instances.data]); - const credentialById = useMemo(() => new Map((credentials.data ?? []).map(c => [c.id, c])), [credentials.data]); - const allCredentials = credentials.data ?? []; + const credentialById = connection.credentialById; const askUnbind = async (r: RepositorySummary) => { const ok = await confirm({ @@ -112,6 +112,8 @@ export function RepositoryListPage({ tab, query, onTabChange, onQueryChange, onO }; const rows = repositories.data ?? []; + // How many of the team's repositories cannot authenticate right now — the number a person scanning the page needs before any row. + const brokenCount = rows.filter(r => connection.issueOf(r) !== null).length; const filtered = rows.filter(r => { const instance = instanceById.get(r.providerInstanceId); @@ -198,6 +200,12 @@ export function RepositoryListPage({ tab, query, onTabChange, onQueryChange, onO
+ {brokenCount > 0 && ( +
+
{brokenCount === 1 ? "1 repository needs a credential" : `${brokenCount} repositories need a credential`}
+
Their credential was refused, disconnected or retired, so runs and webhooks through {brokenCount === 1 ? "it" : "them"} fail. Each one below says how to fix it.
+
+ )} {repositories.isLoading && (
Loading…
)} @@ -224,10 +232,12 @@ export function RepositoryListPage({ tab, query, onTabChange, onQueryChange, onO {filtered.map(r => { const instance = instanceById.get(r.providerInstanceId); const credential = r.credentialId ? credentialById.get(r.credentialId) : undefined; + const issue = connection.issueOf(r); return ( onOpenRepo(r.fullPath)} > @@ -236,7 +246,7 @@ export function RepositoryListPage({ tab, query, onTabChange, onQueryChange, onO
{r.name} - + {issue ? : }
{r.fullPath} @@ -248,16 +258,11 @@ export function RepositoryListPage({ tab, query, onTabChange, onQueryChange, onO {r.visibility.toLowerCase()}
- {/* Error-state CTA — always visible (not hover-only) because a broken - repo needs a clear, discoverable fix. The action sits on the row's - second line so it can't be missed even at a glance. */} - {r.status === "Error" && mayManageRepos && ( - setConnectOpen(true)} - /> - )} + {/* A broken connection's way out — always visible (not hover-only) because a broken repo needs a clear, + discoverable fix, and shown for ANY repository whose credential is not healthy, not only the ones + already flipped to Error: a repository whose credential was refused or disconnected stays "Active" and + fails every run until someone looks. */} + {issue && setConnectOpen(true)} />}
@@ -276,9 +281,8 @@ export function RepositoryListPage({ tab, query, onTabChange, onQueryChange, onO {formatRelative(r.lastEventDate ?? r.createdDate)} - {/* Hover-only toolbar is for normal-state ops (Open / Unbind). The - re-link affordance was moved out — see RepoCredentialIssueAction - on the row's name cell, which is always visible on Error rows. */} + {/* Hover-only toolbar is for normal-state ops (Open / Unbind). The fix for a broken connection is not + here — see RepositoryConnectionFix on the row's name cell, which is always visible. */} @@ -333,7 +337,7 @@ function formatRelative(iso?: string | null) { /** * Inline status pill next to the repo name. Active = nothing shown. Other statuses get a * compact label chip. For Error specifically, the chip is PURELY informational here — the - * action lives in RepoCredentialIssueAction below the URL, which is always visible and + * action lives in RepositoryConnectionFix below the URL, which is always visible and * can't be missed. Keeping the chip + action separate stops the chip from over-promising * interactivity at every screen size. */ @@ -360,134 +364,5 @@ function RepoStatusBadge({ status, lastError }: { status: RepositorySummary["sta ); } -/** - * Always-visible recovery affordance for Error-state rows. Sits inline on the row's - * second line so the user sees it the instant the table loads — no hover discoverability - * problem like the old toolbar-icon design had. - * - * Two click destinations depending on whether there's a candidate credential: - * • At least one active credential of the same provider → opens the picker popover. - * • Zero candidates → opens the picker with a "Connect new credential" CTA that - * opens the Providers modal (via onConnectNew). Avoids dead-ending the user. - * - * Portal'd popover with flip-up positioning, same recipe as the kebab in the provider - * list — keeps the menu visible even on the last row near the table edge. - */ -function RepoCredentialIssueAction({ - repository, - allCredentials, - onConnectNew, -}: { - repository: RepositorySummary; - allCredentials: CredentialSummary[]; - onConnectNew: () => void; -}) { - const [open, setOpen] = useState(false); - const [pos, setPos] = useState<{ top: number; left: number } | null>(null); - const triggerRef = useRef(null); - const relink = useRelinkRepositoryCredential(); - const alert = useAlert(); - - const candidates = useMemo(() => - allCredentials.filter(c => - c.providerInstanceId === repository.providerInstanceId - && c.status === "Active" - && c.id !== repository.credentialId, - ), - [allCredentials, repository.providerInstanceId, repository.credentialId]); - - useEffect(() => { - if (!open || !triggerRef.current) return; - const POP_HEIGHT = Math.max(80, 48 + candidates.length * 36); - const POP_GAP = 6; - const rect = triggerRef.current.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.bottom; - const flipUp = spaceBelow < POP_HEIGHT + POP_GAP + 16; - setPos({ - top: flipUp ? rect.top - POP_HEIGHT - POP_GAP : rect.bottom + POP_GAP, - left: rect.left, - }); - }, [open, candidates.length]); - - useEffect(() => { - if (!open) return; - const onClick = (e: MouseEvent) => { - const t = e.target as HTMLElement; - if (!t.closest(".relink-trigger") && !t.closest(".relink-pop")) setOpen(false); - }; - const onScroll = () => setOpen(false); - window.addEventListener("click", onClick); - window.addEventListener("scroll", onScroll, true); - return () => { - window.removeEventListener("click", onClick); - window.removeEventListener("scroll", onScroll, true); - }; - }, [open]); - - const pick = async (credentialId: string) => { - setOpen(false); - try { - await relink.mutateAsync({ repositoryId: repository.id, credentialId }); - } catch (err) { - await alert({ - title: "Couldn't re-link credential", - message: err instanceof Error ? err.message : "Unexpected error.", - variant: "error", - }); - } - }; - - const connectNew = () => { - setOpen(false); - onConnectNew(); - }; - - const hasCandidates = candidates.length > 0; - - return ( -
- - {open && pos && createPortal( -
e.stopPropagation()} - > -
- {hasCandidates ? "Re-link to credential" : "No active credential"} -
- {hasCandidates ? ( - candidates.map(c => ( - - )) - ) : ( - <> -
- No other active credentials for this provider. Connect one from Providers, then come - back and pick it here. -
- - - )} -
, - document.body, - )} -
- ); -} - export { PROVIDER_LABEL }; export type { RepositorySummary, ProviderInstanceSummary }; diff --git a/frontend/src/_imported/ai-code-space/repo-detail.tsx b/frontend/src/_imported/ai-code-space/repo-detail.tsx index 8826fa90b..ab16dcfe1 100644 --- a/frontend/src/_imported/ai-code-space/repo-detail.tsx +++ b/frontend/src/_imported/ai-code-space/repo-detail.tsx @@ -33,6 +33,8 @@ import { formatBytes } from "@/lib/codeTree"; import { DiffViewer } from "./diff-viewer"; import { Ic } from "./icons"; import { Pager } from "./pager"; +import { ConnectionIssueChip, RepositoryConnectionFix } from "@/components/remedy/RepositoryConnectionIssue"; +import { useRepositoryConnectionIssues } from "@/hooks/use-repository-connection-issues"; /** * Repository detail. Split into URL-driven pieces: @@ -84,6 +86,7 @@ interface RepoDetailHeaderProps { export function RepoDetailHeader({ repoId, activeTab, onTabChange, teamSlug, children }: RepoDetailHeaderProps) { const repository = useRepository(repoId); const instances = useProviderInstances(); + const connection = useRepositoryConnectionIssues(); const navigate = useNavigate(); const [launchOpen, setLaunchOpen] = useState(false); @@ -193,6 +196,7 @@ export function RepoDetailHeader({ repoId, activeTab, onTabChange, teamSlug, chi // yet) could return undefined. Fall back to just "Projects / {repo.name}" // in that case rather than rendering an empty middle crumb. const hasParentProject = !!repo.projectId && !!repo.projectSlug && !!repo.projectName; + const issue = connection.issueOf(repo); return (
@@ -269,6 +273,16 @@ export function RepoDetailHeader({ repoId, activeTab, onTabChange, teamSlug, chi + {/* The repository's connection, when it does not work: what is wrong and the way out, above the tabs — every tab below reads + through this credential, so a dead one is the first thing to know, not a status word in the Overview. */} + {issue && ( +
+ + {issue.title} + +
+ )} + {launchOpen && ( + fetchJson(`/api/credentials/${encodeURIComponent(credentialId)}/relink-suggestions`), + // Pre-revoke preview — drives the confirm dialog's impact wording. getCredentialUsage: (credentialId: string) => fetchJson(`/api/credentials/${encodeURIComponent(credentialId)}/usage`), diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 2c612e0c0..5b1c82225 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -404,6 +404,11 @@ export interface DiagnosticBlock extends RoomBlockBase { text: string; actions?: RoomAction[] | null; rawDetail?: string | null; + /** + * The code of the provider failure the run recorded — a connection disconnected, refused or redirecting. Static, so it is + * safe in the cached turn; the card it asks for (what to do, and who may) is read live from the run's remedy. + */ + failureCode?: string | null; } export interface DecisionBlock extends RoomBlockBase { type: "decision"; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 2a884e63f..00e629553 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -51,6 +51,8 @@ export interface CredentialSummary { health?: CredentialHealth; /** Whole days until a static token expires at its provider; null for an OAuth credential and for a token with no recorded expiry. */ daysToExpiry?: number | null; + /** How many live repositories are bound through it — what reconnecting, replacing or disconnecting it puts at stake. Absent from a server that predates it; read it as zero. */ + boundRepositoryCount?: number; } export interface InitOAuthResponse { @@ -72,6 +74,89 @@ export interface RevokeCredentialResponse { affectedRepositoryCount: number; } +/** + * The kinds of fix a remedy offers. The backend names them; the page renders the ones it is told are enabled, and only the + * backend decides which that is for the person looking. + */ +export type RemedyActionKind = "relink" | "reconnect" | "replace_token" | "edit_provider_url" | "open_providers" | "rerun"; + +export interface RemedyAction { + kind: RemedyActionKind; + label: string; + /** False when the caller may not do this — shown disabled, with `disabledReason`, never hidden. */ + enabled: boolean; + disabledReason?: string | null; +} + +/** The credential a remedy is about. */ +export interface RemedyCredential { + id: string; + displayName: string; + ownership: CredentialOwnership; + ownerUserId?: string | null; + ownerName?: string | null; + authType: AuthType; + status: CredentialStatus; + health: CredentialHealth; + /** Soft-deleted: its person left the team or its connection was removed. It cannot be reconnected; what leans on it can only be moved. */ + retired: boolean; + providerInstanceId: string; +} + +/** A repository the failing step acted on, and whether what it runs through works now. */ +export interface RemedyRepository { + id: string; + fullPath: string; + status: RepositoryStatus; + credentialId?: string | null; + credentialWorks: boolean; +} + +export interface RemedyProviderInstance { + id: string; + displayName: string; + provider: ProviderKind; + baseUrl: string; +} + +export interface RemedyPerson { + userId: string; + name: string; + /** Why this person: "Owner" or "Admin" of the team, or "Owner of the credential". */ + role: string; +} + +/** + * What is wrong with a provider connection, and what the CALLER can do about it — read fresh for whoever is looking, so the + * same failure reads one way to an Owner and another to a Viewer, and reads `resolved` once the connection works again. The + * words are the backend's: the page renders them and never maps a code to copy of its own. + */ +export interface ProviderRemedy { + /** The failure's stable code (or, for a refused trigger delivery, its refusal reason). */ + code: string; + title: string; + cause: string; + resolved: boolean; + credential?: RemedyCredential | null; + repositories: RemedyRepository[]; + providerInstance?: RemedyProviderInstance | null; + /** For a redirecting provider: the origin it answered with — what its base URL would be edited to. */ + redirectsTo?: string | null; + actions: RemedyAction[]; + askPeople: RemedyPerson[]; +} + +/** A credential the caller could move a dead credential's bindings onto, best first. */ +export interface RelinkSuggestion { + credentialId: string; + displayName: string; + authType: AuthType; + ownership: CredentialOwnership; + kind: "YourConnection" | "TeamServiceToken" | "OwnersOtherConnection"; + createdDate: string; + connectedBeforeDisconnect: boolean; +} + export interface ReplaceCredentialTokenResponse { credentialId: string; /** The provider account the new token belongs to, when the provider could say — what the person can check it against. */ @@ -316,6 +401,7 @@ export type RejectionReason = | "signature_invalid" | "webhook_inactive" | "event_not_mapped" | "malformed_payload" | "no_matching_activation" | "repository_not_bound" | "webhook_retired" | "delivery_id_missing" | "delivery_replayed" | "author_not_member" | "pull_request_debounced" + | "credential_rejected" | "author_standing_unknown" | (string & {}); /** One delivery that arrived and was refused. Every field here was captured at rejection with secrets already stripped. */ @@ -340,6 +426,12 @@ export interface RepositoryRejectedDeliveries { deliveries: RejectedDelivery[]; /** A full list cannot be told apart from exactly-the-cap, so the page says the list stops here rather than claiming more exist. It uses this number rather than one of its own. */ cap: number; + /** + * What to do about the refusals that blame the repository's credential (`credential_rejected`, `author_standing_unknown`), + * for the person asking. Present only when the list holds one; one remedy serves every such row, because it is about the + * credential the repository runs through now and not about any one delivery. + */ + remedy?: ProviderRemedy | null; } export interface RemoteRepository { diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 7f881b7ca..2b5e6e52b 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -1,5 +1,6 @@ import { ApiError, fetchJson, fetchResponse } from "./request"; import type { RoomFileIdentity, RoomPullRequestResult } from "./sessions"; +import type { ProviderRemedy } from "./types"; // ─── Types (mirror backend DTOs) ─────────────────────────────────────────────── @@ -462,6 +463,11 @@ export interface WorkflowRunDetail { outputs?: unknown; /** Set when the run is Suspended — tells the UI why it's paused + what affordance to show. */ pendingWait?: WorkflowRunWaitInfo | null; + /** + * What is wrong with the provider connection the run failed on, and what the CALLER can do about it. Present only for a run + * whose recorded failure is one a person can repair; absent for every other run and for a run recorded before failures were kept. + */ + remedy?: ProviderRemedy | null; } /** @@ -1655,6 +1661,13 @@ export const workflowsApi = { method: "POST", }), + /** + * What is wrong with the provider connection a run failed on, and what the caller can do about it — the lean read behind the + * run page's remediation card. `null` when the run recorded no failure a person can repair (the API answers 204). + */ + getRunRemedy: async (runId: string): Promise => + (await fetchJson(`/api/workflows/runs/${encodeURIComponent(runId)}/remedy`)) ?? null, + /** * The Room's "Open PR" action (PR-6): opens (or, on a repeat call, reuses) a pull/merge request for a terminal * run's published branch(es). One entry per repository the run published to. diff --git a/frontend/src/components/connect-remote-modal.test.tsx b/frontend/src/components/connect-remote-modal.test.tsx index a87961bb6..55600484f 100644 --- a/frontend/src/components/connect-remote-modal.test.tsx +++ b/frontend/src/components/connect-remote-modal.test.tsx @@ -236,7 +236,7 @@ describe("connect remote modal re-authorizing a credential in place", () => { }); it("keeps a credential the provider refused in the list, marked, with the action that brings it back", async () => { - renderAs(admin, [credential("oauth-1", "OAuth", { status: "Error", lastError: "OAuth exchange rejected: invalid_grant" }), credential("pat-1", "Pat", { status: "Error" }), credential("gone", "Pat", { status: "Revoked" })]); + renderAs(admin, [credential("oauth-1", "OAuth", { status: "Error", lastError: "OAuth exchange rejected: invalid_grant" }), credential("pat-1", "Pat", { status: "Error" })]); await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); @@ -247,6 +247,27 @@ describe("connect remote modal re-authorizing a credential in place", () => { expect(screen.queryByRole("button", { name: /Connect with OAuth/ })).toBeNull(); }); + it("lists a disconnected credential too, marked, with how many repositories are still bound and a way back — and no second Disconnect", async () => { + renderAs(admin, [credential("oauth-1", "OAuth", { status: "Revoked", boundRepositoryCount: 3 }), credential("pat-1", "Pat", { status: "Revoked", boundRepositoryCount: 1 })]); + + await waitFor(() => expect(screen.getAllByText("disconnected")).toHaveLength(2)); + + expect(screen.getAllByTestId("bound-repositories").map((e) => e.textContent)).toEqual(["3 repositories bound", "1 repository bound"]); + expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Replace token" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Disconnect" })).toBeNull(); + expect(screen.queryByText("connected")).toBeNull(); // a disconnected credential is listed, but it is not a connection + }); + + it("puts the credentials that need a person first and says how many repositories each holds", async () => { + renderAs(admin, [credential("pat-1", "Pat", { status: "Active", boundRepositoryCount: 2, createdDate: "2026-03-01T00:00:00Z" }), credential("oauth-1", "OAuth", { status: "Error", boundRepositoryCount: 5, createdDate: "2026-01-01T00:00:00Z" })]); + + await waitFor(() => expect(screen.getByText("needs reconnect")).toBeTruthy()); + + const rows = screen.getAllByTestId("bound-repositories").map((e) => e.textContent); + expect(rows).toEqual(["5 repositories bound", "2 repositories bound"]); // the refused one leads although it is older + }); + it("marks a token whose expiry has passed as needing a new one although nothing has marked the row yet", async () => { // The row is still Active — the sweep has not looked — but the backend derives `rejected` from the recorded expiry. renderAs(member, [credential("pat-1", "Pat", { status: "Active", health: "rejected", expiresDate: "2026-01-02T00:00:00Z", daysToExpiry: 0 })]); @@ -269,12 +290,19 @@ describe("connect remote modal re-authorizing a credential in place", () => { expect(screen.getByRole("button", { name: "Replace token" })).toBeTruthy(); }); - it("says nothing about a credential that is fine", async () => { + it("marks a credential that is fine as active, with no warning", async () => { renderAs(member, [credential("oauth-1", "OAuth", { health: "active" }), credential("pat-1", "Pat", { health: "active", daysToExpiry: null })]); await waitFor(() => expect(screen.getAllByRole("button", { name: "Disconnect" })).toHaveLength(2)); - expect(screen.queryByText(/needs reconnect|needs a new token|expires/)).toBeNull(); + expect(screen.getAllByText("active")).toHaveLength(2); + expect(screen.queryByText(/needs reconnect|needs a new token|expires|disconnected|owner left/)).toBeNull(); + }); + + it("says a credential lent by someone who left the team is about to be retired", async () => { + renderAs(admin, [credential("pat-1", "Pat", { health: "owner_left" })]); + + await waitFor(() => expect(screen.getByText("owner left")).toBeTruthy()); }); it("marks a team token the provider refused, beside the Replace token that brings it back", async () => { diff --git a/frontend/src/components/identities/ReplaceTokenModal.tsx b/frontend/src/components/identities/ReplaceTokenModal.tsx index 0ee1fd2d7..db58efd47 100644 --- a/frontend/src/components/identities/ReplaceTokenModal.tsx +++ b/frontend/src/components/identities/ReplaceTokenModal.tsx @@ -8,7 +8,7 @@ import { useReplaceCredentialToken } from "@/hooks/use-credentials"; interface ReplaceTokenModalProps { /** The credential whose token is replaced. It keeps its id, so everything bound through it keeps working. */ - credential: CredentialSummary; + credential: Pick; /** Human label for the dialog header, e.g. "GitLab · gitlab.com". */ providerLabel: string; onClose: () => void; diff --git a/frontend/src/components/remedy/ProviderRemedyCard.test.tsx b/frontend/src/components/remedy/ProviderRemedyCard.test.tsx new file mode 100644 index 000000000..d793d3650 --- /dev/null +++ b/frontend/src/components/remedy/ProviderRemedyCard.test.tsx @@ -0,0 +1,367 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { MeTeam, ProviderInstanceSummary, ProviderRemedy, RemedyAction, RemedyActionKind } from "@/api/types"; +import { DialogProvider } from "@/components/dialog"; + +import { ProviderRemedyCard } from "./ProviderRemedyCard"; + +/** + * The remediation card renders the BACKEND's remedy: its words, its list of actions, and for each whether the person looking may press + * it. It decides nothing about permission and maps no code to copy of its own — these tests hand it remedies shaped as the API sends them + * (an Owner's, a Member's, a Viewer's, a fixed one) and pin that each button does what its label says, against the endpoint that does it. + */ +const team: MeTeam = { id: "t1", slug: "acme", name: "Acme", kind: "Workspace", role: "Admin", permissions: ["repos.manage", "credentials.manage"], memberCount: 3, repositoryCount: 1, projectCount: 1, workflowCount: 0 }; +const instance: ProviderInstanceSummary = { id: "pi1", teamId: "t1", provider: "GitLab", displayName: "Acme GitLab", baseUrl: "http://gitlab.test", createdDate: "2026-01-01T00:00:00Z", oauthEnabled: true }; + +const action = (kind: RemedyActionKind, over: Partial = {}): RemedyAction => ({ kind, label: LABELS[kind], enabled: true, ...over }); +const LABELS: Record = { relink: "Re-link repository", reconnect: "Reconnect GitLab", replace_token: "Replace token", edit_provider_url: "Edit provider address", open_providers: "Open Providers", rerun: "Re-run" }; + +const remedy = (over: Partial = {}): ProviderRemedy => ({ + code: "credential_needs_reconnect", + title: "GitLab no longer accepts the credential this run used", + cause: "GitLab refused to renew the sign-in behind “GitLab · nacho” so acme/api cannot authenticate.", + resolved: false, + credential: { id: "c-dead", displayName: "GitLab · nacho", ownership: "Personal", ownerUserId: "u9", ownerName: "Nacho", authType: "OAuth", status: "Error", health: "rejected", retired: false, providerInstanceId: "pi1" }, + repositories: [{ id: "r1", fullPath: "acme/api", status: "Error", credentialId: "c-dead", credentialWorks: false }], + providerInstance: { id: "pi1", displayName: "Acme GitLab", provider: "GitLab", baseUrl: "http://gitlab.test" }, + actions: [action("reconnect"), action("relink"), action("open_providers"), action("rerun", { enabled: false, disabledReason: "Fix the connection first, then run it again." })], + askPeople: [], + ...over, +}); + +type Call = { method: string; url: string; body: unknown }; + +function renderCard(r: ProviderRemedy, props: { onRerun?: () => void; rawDetail?: string } = {}, answers: Record = {}) { + localStorage.setItem("codespace.jwt", "test-jwt"); + localStorage.setItem("codespace.activeTeamId", team.id); + + const calls: Call[] = []; + const credentials = [ + { id: "c-dead", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u9", ownerUserName: "Nacho", ownership: "Personal", authType: "OAuth", displayName: "GitLab · nacho", status: "Error", health: "rejected", createdDate: "2026-01-01T00:00:00Z" }, + { id: "c-mine", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u1", ownerUserName: "Maya", ownership: "Personal", authType: "OAuth", displayName: "Maya's GitLab", status: "Active", health: "active", createdDate: "2026-02-01T00:00:00Z" }, + { id: "c-team", teamId: "t1", providerInstanceId: "pi1", ownerUserId: null, ownerUserName: null, ownership: "TeamService", authType: "GroupAccessToken", displayName: "Acme group token", status: "Active", health: "active", createdDate: "2026-02-02T00:00:00Z" }, + { id: "c-ada", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u2", ownerUserName: "Ada", ownership: "Personal", authType: "Pat", displayName: "Ada's token", status: "Active", health: "active", createdDate: "2026-02-03T00:00:00Z" }, + { id: "c-other-host", teamId: "t1", providerInstanceId: "pi2", ownerUserId: "u1", ownerUserName: "Maya", ownership: "Personal", authType: "Pat", displayName: "Maya's GitHub", status: "Active", health: "active", createdDate: "2026-02-04T00:00:00Z" }, + { id: "c-revoked", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u1", ownerUserName: "Maya", ownership: "Personal", authType: "Pat", displayName: "Maya's old token", status: "Revoked", health: "revoked", createdDate: "2025-02-04T00:00:00Z" }, + ]; + // A test's own answers come first so a specific route wins over a prefix ("/api/credentials/oauth/init" over "/api/credentials"); one that names a base route replaces it. + const base: Record = { + "/api/users/me": { id: "u1", email: "u@test.local", name: "Maya", teams: [team], permissions: [], passwordMustChange: false }, + "/relink-suggestions": [{ credentialId: "c-team", displayName: "Acme group token", authType: "GroupAccessToken", ownership: "TeamService", kind: "TeamServiceToken", createdDate: "2026-02-02T00:00:00Z", connectedBeforeDisconnect: false }], + "/capabilities": { credentialId: "any", grantedScopes: [], capabilities: [] }, + "/api/provider-instances": [instance], + "/api/credentials": credentials, + }; + const routes: Record = { ...answers, ...Object.fromEntries(Object.entries(base).filter(([key]) => !(key in answers))) }; + + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + + const key = Object.keys(routes).find((k) => url.includes(k)); + const body = key === undefined ? undefined : routes[key]; + return new Response(body === undefined ? "" : JSON.stringify(body), { status: body === undefined ? 404 : 200, headers: { "Content-Type": "application/json" } }); + })); + + render( + + + , + ); + + return calls; +} + +afterEach(() => { localStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +describe("ProviderRemedyCard — what it says", () => { + it("shows the backend's title and cause, the credential and the repository the failure names, and each action", () => { + renderCard(remedy(), { onRerun: vi.fn() }); + + expect(screen.getByText("GitLab no longer accepts the credential this run used")).toBeTruthy(); + expect(screen.getByText(/refused to renew the sign-in behind/)).toBeTruthy(); + expect(screen.getByText("GitLab · nacho")).toBeTruthy(); + expect(screen.getByText("Nacho")).toBeTruthy(); // whose credential it is + expect(screen.getByText("acme/api")).toBeTruthy(); + // Re-run is held back until the fix shows, so it is not a dead button beside the live ones. + expect(screen.getAllByRole("button").map((b) => b.textContent?.trim())).toEqual(["Reconnect GitLab", "Re-link repository", "Open Providers"]); + }); + + it("leads with the first action that is the caller's, in the primary style", () => { + renderCard(remedy()); + + expect(screen.getByRole("button", { name: "Reconnect GitLab" }).className).toContain("room-btn-primary"); + expect(screen.getByRole("button", { name: "Re-link repository" }).className).not.toContain("room-btn-primary"); + }); + + it("keeps a raw error behind a toggle, as the diagnostic it stands in for does", () => { + renderCard(remedy(), { rawDetail: "The credential 'GitLab · nacho' (c-dead) cannot authenticate: its provider refused it (invalid_grant)." }); + + expect(screen.queryByText(/invalid_grant/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /Show raw error/ })); + + expect(screen.getByText(/invalid_grant/)).toBeTruthy(); + }); +}); + +describe("ProviderRemedyCard — a failure that is nobody's credential to repair", () => { + it("says what the provider did and offers only what the backend offered: no repair button for a renewal that was not answered", () => { + renderCard(remedy({ + code: "oauth_exchange_failed", + title: "Renewing the sign-in this run used did not work", + cause: "GitLab did not renew the sign-in behind “GitLab · nacho” when acme/api needed it (it answered http_503). That says nothing about the credential itself.", + actions: [action("open_providers"), action("rerun")], + }), { onRerun: vi.fn() }); + + expect(screen.getByText("Renewing the sign-in this run used did not work")).toBeTruthy(); + expect(screen.getByText(/it answered http_503/)).toBeTruthy(); + expect(screen.getAllByRole("button").map((b) => b.textContent?.trim())).toEqual(["Open Providers", "Re-run"]); // in the order the backend gave, which is its judgement of what to try first + expect(screen.queryByRole("note")).toBeNull(); + }); + + it("a redirect the server would refuse to edit away shows its cause and no Edit provider address button", () => { + renderCard(remedy({ + code: "provider_base_url_redirects", + title: "The provider's address redirects somewhere else", + cause: "Requests to http://gitlab.test are redirected to https://gitlab.example.com, which is another host. The provider's address cannot be changed while 1 credential is connected through it. Disconnect them first.", + redirectsTo: "https://gitlab.example.com", + credential: null, + repositories: [], + actions: [action("open_providers"), action("rerun", { enabled: false, disabledReason: "Fix the connection first, then run it again." })], + }), { onRerun: vi.fn() }); + + expect(screen.getByText(/Disconnect them first/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Edit provider address" })).toBeNull(); + }); +}); + +describe("ProviderRemedyCard — who may press what", () => { + const memberView = remedy({ + actions: [ + action("reconnect", { enabled: false, disabledReason: "Needs the Admin or Owner role." }), + action("relink", { enabled: false, disabledReason: "Needs the Admin or Owner role." }), + action("open_providers"), + action("rerun", { enabled: false, disabledReason: "Fix the connection first, then run it again." }), + ], + askPeople: [{ userId: "u3", name: "Olive", role: "Owner" }, { userId: "u2", name: "Ada", role: "Admin" }], + }); + + it("gives a Member no dead buttons: what is not theirs is a line saying which fix, what it takes and who to ask", () => { + renderCard(memberView, { onRerun: vi.fn() }); + + expect(screen.getAllByRole("button").map((b) => b.textContent?.trim())).toEqual(["Open Providers"]); // no disabled Reconnect, Re-link or Re-run + + const ask = screen.getByRole("note"); + expect(ask.textContent).toContain("Reconnect GitLab — Needs the Admin or Owner role."); + expect(ask.textContent).toContain("Re-link repository — Needs the Admin or Owner role."); + expect(ask.textContent).toContain("Ask Olive (Owner) or Ada (Admin)."); + }); + + it("says nothing about who to ask when every fix is the caller's", () => { + renderCard(remedy({ askPeople: [] })); + + expect(screen.queryByRole("note")).toBeNull(); + }); + + it("does not offer Providers as the primary action when it is all the caller can do", () => { + renderCard(memberView); + + expect(screen.getByRole("button", { name: "Open Providers" }).className).not.toContain("room-btn-primary"); + }); +}); + +describe("ProviderRemedyCard — the same failure for an Owner, a Member and a Viewer", () => { + const forRole = (reconnect: boolean, relink: boolean, askPeople: ProviderRemedy["askPeople"]) => remedy({ + actions: [ + action("reconnect", { enabled: reconnect, disabledReason: reconnect ? null : "Needs the Admin or Owner role." }), + action("relink", { enabled: relink, disabledReason: relink ? null : "Needs the Admin or Owner role." }), + action("open_providers"), + ], + askPeople, + }); + const who = [{ userId: "u3", name: "Olive", role: "Owner" }, { userId: "u2", name: "Ada", role: "Admin" }]; + + it.each([ + ["Owner", forRole(true, true, []), ["Reconnect GitLab", "Re-link repository", "Open Providers"], false], + ["Member", forRole(false, false, who), ["Open Providers"], true], + ["Viewer", forRole(false, false, who), ["Open Providers"], true], + ] as const)("%s: presses only what is enabled, and is told who to ask when something is not theirs", (_role, r, enabledButtons, asked) => { + renderCard(r); + + const enabled = screen.getAllByRole("button").filter((b) => !(b as HTMLButtonElement).disabled).map((b) => b.textContent?.trim()); + + expect(enabled).toEqual(enabledButtons); + expect(screen.queryByRole("note") !== null).toBe(asked); + }); + + it("a retired credential leaves re-linking the only repair and says how many repositories it moves", () => { + renderCard(remedy({ + credential: { ...remedy().credential!, retired: true, status: "Revoked", health: "revoked" }, + repositories: [{ id: "r1", fullPath: "acme/api", status: "Error", credentialId: "c-dead", credentialWorks: false }, { id: "r2", fullPath: "acme/web", status: "Error", credentialId: "c-dead", credentialWorks: false }], + actions: [action("relink", { label: "Re-link repositories" }), action("open_providers")], + })); + + expect(screen.getAllByRole("button").map((b) => b.textContent?.trim())).toEqual(["Re-link repositories", "Open Providers"]); + }); + + it("re-linking several repositories moves each of them", async () => { + const calls = renderCard(remedy({ + repositories: [{ id: "r1", fullPath: "acme/api", status: "Error", credentialId: "c-dead", credentialWorks: false }, { id: "r2", fullPath: "acme/web", status: "Error", credentialId: "c-dead", credentialWorks: false }], + actions: [action("relink", { label: "Re-link repositories" })], + }), {}, { "/relink-credential": {} }); + + fireEvent.click(screen.getByRole("button", { name: "Re-link repositories" })); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Acme group token")).toBeTruthy()); + expect(within(dialog).getByText("Re-link 2 repositories")).toBeTruthy(); + + fireEvent.click(within(dialog).getByText("Acme group token")); + + await waitFor(() => expect(calls.filter((c) => c.url.endsWith("/relink-credential"))).toHaveLength(2)); + expect(calls.filter((c) => c.url.endsWith("/relink-credential")).map((c) => c.url.match(/repositories\/([^/]+)\//)?.[1])).toEqual(["r1", "r2"]); + }); + + it("only moves the repositories that still need it", async () => { + const calls = renderCard(remedy({ + repositories: [{ id: "r1", fullPath: "acme/api", status: "Error", credentialId: "c-dead", credentialWorks: false }, { id: "r2", fullPath: "acme/web", status: "Active", credentialId: "c-team", credentialWorks: true }], + actions: [action("relink")], + }), {}, { "/relink-credential": {} }); + + fireEvent.click(screen.getByRole("button", { name: "Re-link repository" })); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Acme group token")).toBeTruthy()); + fireEvent.click(within(dialog).getByText("Acme group token")); + + await waitFor(() => expect(calls.some((c) => c.url.endsWith("/relink-credential"))).toBe(true)); + expect(calls.filter((c) => c.url.endsWith("/relink-credential"))).toHaveLength(1); + }); +}); + +describe("ProviderRemedyCard — each button does what it says", () => { + it("Reconnect signs in again to the SAME credential, so nothing bound through it has to move", async () => { + vi.spyOn(window, "open").mockReturnValue(null); // a blocked popup ends the flow right after the init request, which is the request under test + const calls = renderCard(remedy(), {}, { "/api/credentials/oauth/init": { authorizeUrl: "https://gitlab.test/oauth/authorize", state: "s" } }); + + fireEvent.click(screen.getByRole("button", { name: "Reconnect GitLab" })); + + await waitFor(() => expect(calls.some((c) => c.url.endsWith("/api/credentials/oauth/init"))).toBe(true)); + const init = calls.find((c) => c.url.endsWith("/api/credentials/oauth/init"))!; + expect(init.method).toBe("POST"); + expect(init.body).toMatchObject({ providerInstanceId: "pi1", displayName: "GitLab · nacho", replaceCredentialId: "c-dead" }); + }); + + it("Replace token puts a pasted token into the same credential", async () => { + const calls = renderCard( + remedy({ credential: { ...remedy().credential!, authType: "Pat" }, actions: [action("replace_token"), action("open_providers")] }), + {}, + { "/api/credentials/c-dead/token": { credentialId: "c-dead", providerUsername: "nacho" } }, + ); + + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + const dialog = await screen.findByRole("dialog", { name: "Replace token" }); + fireEvent.change(within(dialog).getByPlaceholderText(/glpat/i), { target: { value: "glpat-fresh" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Replace token" })); + + await waitFor(() => expect(calls.some((c) => c.method === "PUT")).toBe(true)); + const put = calls.find((c) => c.method === "PUT")!; + expect(put.url).toMatch(/\/api\/credentials\/c-dead\/token$/); + expect(put.body).toEqual({ token: "glpat-fresh" }); + }); + + it("Re-link offers only the caller's own and the team's credentials on the repository's provider, best suggestion first, and moves the repository", async () => { + const calls = renderCard(remedy(), {}, { "/api/repositories/r1/relink-credential": {} }); + + fireEvent.click(screen.getByRole("button", { name: "Re-link repository" })); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Acme group token")).toBeTruthy()); + + const offered = within(dialog).getAllByRole("listitem").map((e) => e.textContent); + expect(offered).toEqual([expect.stringContaining("Acme group token"), expect.stringContaining("Maya's GitLab")]); // the suggestion first; never Ada's personal token, the dead one, a revoked one or another host's + expect(within(dialog).queryByText("Ada's token")).toBeNull(); + expect(within(dialog).queryByText("Maya's GitHub")).toBeNull(); + + fireEvent.click(within(dialog).getByText("Acme group token")); + + await waitFor(() => expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/api/repositories/r1/relink-credential"))).toBe(true)); + expect(calls.find((c) => c.url.endsWith("/api/repositories/r1/relink-credential"))!.body).toEqual({ newCredentialId: "c-team" }); + expect(calls.some((c) => c.url.includes("/api/credentials/c-dead/relink-suggestions"))).toBe(true); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Re-link credential" })).toBeNull()); + }); + + it("Re-link says what to do when no credential can take the repository", async () => { + renderCard(remedy(), {}, { "/api/credentials": [] }); + + fireEvent.click(screen.getByRole("button", { name: "Re-link repository" })); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("No other working credential")).toBeTruthy()); + expect(within(dialog).getByRole("button", { name: /Open Providers/ })).toBeTruthy(); + }); + + it("Edit provider address opens the provider's edit form starting at the address it answered on", async () => { + renderCard(remedy({ + code: "provider_base_url_redirects", + title: "The provider's address redirects somewhere else", + cause: "Requests to http://gitlab.test are redirected to https://gitlab.test.", + credential: null, repositories: [], redirectsTo: "https://gitlab.test", + actions: [action("edit_provider_url"), action("open_providers"), action("rerun", { enabled: false, disabledReason: "Fix the connection first, then run it again." })], + })); + + fireEvent.click(screen.getByRole("button", { name: "Edit provider address" })); + + const dialog = await screen.findByRole("dialog"); + await waitFor(() => expect(within(dialog).getByText(/Edit GitLab provider/)).toBeTruthy()); + expect((within(dialog).getByDisplayValue("https://gitlab.test") as HTMLInputElement).value).toBe("https://gitlab.test"); // not the stored http:// one + }); + + it("Open Providers opens the Providers dialog", async () => { + renderCard(remedy()); + + fireEvent.click(screen.getByRole("button", { name: "Open Providers" })); + + const dialog = await screen.findByRole("dialog"); + await waitFor(() => expect(within(dialog).getByText("Providers")).toBeTruthy()); + }); +}); + +describe("ProviderRemedyCard — once it is fixed", () => { + const fixed = remedy({ + resolved: true, + title: "This connection works again", + cause: "“GitLab · nacho” works again, so acme/api can authenticate. Run it again to pick up where this stopped.", + actions: [action("rerun"), action("reconnect"), action("open_providers")], + repositories: [{ id: "r1", fullPath: "acme/api", status: "Active", credentialId: "c-dead", credentialWorks: true }], + }); + + it("says so, puts Re-run first, and runs it again", () => { + const onRerun = vi.fn(); + renderCard(fixed, { onRerun }); + + expect(screen.getByRole("status").textContent).toContain("This connection works again"); + expect(screen.getAllByRole("button")[0].textContent).toContain("Re-run"); + expect(screen.getAllByRole("button")[0].className).toContain("room-btn-primary"); + + fireEvent.click(screen.getByRole("button", { name: "Re-run" })); + + expect(onRerun).toHaveBeenCalledOnce(); + }); + + it("offers no Re-run where there is no run behind the card (a refused delivery started none)", () => { + renderCard(fixed); + + expect(screen.queryByRole("button", { name: "Re-run" })).toBeNull(); + }); + + it("holds Re-run back until the fix shows — there is no button for it, because nothing about the run changes until the connection does", () => { + renderCard(remedy(), { onRerun: vi.fn() }); + + expect(screen.queryByRole("button", { name: "Re-run" })).toBeNull(); + }); +}); diff --git a/frontend/src/components/remedy/ProviderRemedyCard.tsx b/frontend/src/components/remedy/ProviderRemedyCard.tsx new file mode 100644 index 000000000..ad67d9607 --- /dev/null +++ b/frontend/src/components/remedy/ProviderRemedyCard.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; + +import { Ic } from "@/_imported/ai-code-space/icons"; +import type { ProviderRemedy, RemedyAction, RemedyActionKind } from "@/api/types"; + +import { useProviderFixes } from "./useProviderFixes"; + +interface ProviderRemedyCardProps { + remedy: ProviderRemedy; + /** Runs the failed turn again. Absent for a card with no run behind it (a refused delivery started none), which then offers no Re-run. */ + onRerun?: () => void | Promise; + rerunning?: boolean; + /** The raw engine error, kept behind "Show raw error" exactly as the diagnostic card it stands in for keeps it. */ + rawDetail?: string | null; + /** Called after a fix landed — the caller re-reads whatever it shows. */ + onFixed?: () => void; +} + +/** + * What is wrong with a provider connection, and the buttons that fix it — rendered from the backend's remedy, never from a code + * this page maps to words of its own. The title and cause are the backend's; so is each action's label and whether the person + * looking may press it. Only what the caller may do is a button. What is not theirs is said as a line instead — which fix, and what it + * takes — with the people who can do it named, so a Member or a Viewer learns that this is fixable, and by whom, rather than meet a + * card of dead buttons. + * + * Once what failed works again the card says so and puts Re-run first — the repair is done, the run only needs another go. Until then + * there is no Re-run to press: nothing about the run changes until the connection does. + */ +export function ProviderRemedyCard({ remedy, onRerun, rerunning = false, rawDetail = null, onFixed }: ProviderRemedyCardProps) { + const fix = useProviderFixes({ onFixed }); + const [showRaw, setShowRaw] = useState(false); + const credential = remedy.credential ?? null; + // What the caller may do is a button; what they may not is a line saying whose it is. A disabled button says "fixable" and then refuses. + const notYours = remedy.actions.filter(a => !a.enabled && a.kind !== "rerun"); + const [primary, ...rest] = remedy.actions.filter(a => a.enabled && (a.kind !== "rerun" || onRerun)); + + const press = (kind: RemedyActionKind) => { + if (kind === "rerun") return void onRerun?.(); + + if (kind === "open_providers") return fix.openProviders(); + if (kind === "edit_provider_url") return remedy.providerInstance ? fix.editProviderAddress(remedy.providerInstance.id, remedy.redirectsTo) : fix.openProviders(); + + if (kind === "relink") { + const instanceId = remedy.providerInstance?.id ?? credential?.providerInstanceId; + const repositories = remedy.repositories.filter(r => !r.credentialWorks); + + return instanceId && repositories.length > 0 ? fix.relink(repositories.map(r => ({ id: r.id, fullPath: r.fullPath })), instanceId, credential?.id ?? null) : fix.openProviders(); + } + + if (!credential) return fix.openProviders(); + + const target = { id: credential.id, displayName: credential.displayName, providerInstanceId: credential.providerInstanceId, authType: credential.authType }; + + return kind === "reconnect" ? void fix.reconnect(target) : fix.replaceToken(target); + }; + + const buttonFor = (action: RemedyAction, isPrimary: boolean) => ( + + ); + + return ( +
+ {remedy.resolved ? : } + +
+
{remedy.title}
+

{remedy.cause}

+ + {(credential || remedy.repositories.length > 0) && ( +
+ {credential && ( + + {credential.displayName} + {credential.ownership === "TeamService" ? "Team service" : credential.ownerName ?? "Personal"} + + )} + {remedy.repositories.map(r => ( + + {r.fullPath} + {r.credentialWorks && } + + ))} +
+ )} + +
+ {primary && buttonFor(primary, true)} + {rest.map(action => buttonFor(action, false))} +
+ + {notYours.length > 0 && ( +
+
Not yours to do:
+
    + {notYours.map(a =>
  • {a.label}{a.disabledReason ? ` — ${a.disabledReason}` : ""}
  • )} +
+ {remedy.askPeople.length > 0 &&
Ask {joinPeople(remedy.askPeople.map(p => `${p.name} (${p.role})`))}.
} +
+ )} + + {rawDetail && rawDetail !== remedy.cause && ( + + )} + {showRaw && rawDetail &&
{rawDetail}
} +
+ + {fix.dialogs} +
+ ); +} + +function actionIcon(kind: RemedyActionKind) { + switch (kind) { + case "relink": return ; + case "reconnect": + case "replace_token": return ; + case "edit_provider_url": return ; + case "open_providers": return ; + case "rerun": return ; + } +} + +/** "Ada", "Ada or Bob", "Ada, Bob or Cy" — a short list a person can read as a sentence. */ +function joinPeople(names: string[]): string { + if (names.length <= 1) return names[0] ?? ""; + + return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`; +} diff --git a/frontend/src/components/remedy/RelinkDialog.tsx b/frontend/src/components/remedy/RelinkDialog.tsx new file mode 100644 index 000000000..2eab841ef --- /dev/null +++ b/frontend/src/components/remedy/RelinkDialog.tsx @@ -0,0 +1,131 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +import { Ic } from "@/_imported/ai-code-space/icons"; +import { ApiError } from "@/api/request"; +import { useCredentials, useRelinkSuggestions } from "@/hooks/use-credentials"; +import { useMe } from "@/hooks/use-me"; +import { useRelinkRepositoryCredential } from "@/hooks/use-repositories"; +import { bindableCredentials, credentialOwnershipLabel, sortCredentialsByPreference } from "@/lib/credentialOrdering"; +import { isLiveCredential } from "@/lib/credentialHealth"; + +export interface RelinkRepository { + id: string; + fullPath: string; +} + +interface RelinkDialogProps { + /** The repositories to move onto the credential that is picked — one, or every one a run touched. */ + repositories: RelinkRepository[]; + /** Only a credential of the repositories' own provider connection can take them; a token never talks to another host. */ + providerInstanceId: string; + /** The credential they are moving off, when there is one: it is asked for suggestions, which put the person's own connection first. */ + fromCredentialId?: string | null; + onClose: () => void; + /** Called once every repository is on the new credential, before the dialog closes. */ + onRelinked?: () => void; + /** Offered when nothing is left to pick: the Providers dialog is where a working credential gets connected. */ + onOpenProviders?: () => void; +} + +/** + * Pick the credential a repository (or several) runs through from now on. Only credentials the caller may bind with are offered — + * their own and the team's service tokens, exactly the set the Add Repository picker offers — because a repository bound through a + * teammate's personal token acts as that person, and stops the day they leave. Suggestions from the backend order the list (the + * connection made before the dead one was disconnected comes first) and never widen it. + */ +export function RelinkDialog({ repositories, providerInstanceId, fromCredentialId = null, onClose, onRelinked, onOpenProviders }: RelinkDialogProps) { + const credentials = useCredentials(); + const me = useMe(); + const suggestions = useRelinkSuggestions(fromCredentialId); + const relink = useRelinkRepositoryCredential(); + const [error, setError] = useState(null); + const [pickedId, setPickedId] = useState(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const candidates = useMemo(() => { + const live = (credentials.data ?? []).filter(c => c.providerInstanceId === providerInstanceId && c.id !== fromCredentialId && isLiveCredential(c)); + const rank = new Map((suggestions.data ?? []).map((s, index) => [s.credentialId, index])); + + return sortCredentialsByPreference(bindableCredentials(live, me.data?.id)).sort((a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)); + }, [credentials.data, me.data?.id, providerInstanceId, fromCredentialId, suggestions.data]); + + const connectedBefore = useMemo(() => new Set((suggestions.data ?? []).filter(s => s.connectedBeforeDisconnect).map(s => s.credentialId)), [suggestions.data]); + + const pick = async (credentialId: string) => { + setError(null); + setPickedId(credentialId); + + try { + for (const repository of repositories) await relink.mutateAsync({ repositoryId: repository.id, credentialId }); + onRelinked?.(); + onClose(); + } catch (err) { + setPickedId(null); + setError(err instanceof ApiError || err instanceof Error ? err.message : "Couldn't re-link."); + } + }; + + const what = repositories.length === 1 ? repositories[0].fullPath : `${repositories.length} repositories`; + + return createPortal( + <> +
+
+
+
+
Re-link {what}
+
Pick the credential {repositories.length === 1 ? "it" : "they"} should run through from now on.
+
+ +
+ +
+ {repositories.length > 1 && ( +
    + {repositories.map(r =>
  • {r.fullPath}
  • )} +
+ )} + + {credentials.isLoading &&
Loading…
} + + {!credentials.isLoading && candidates.length === 0 && ( +
+
No other working credential
+
Nothing connected for this provider can take {repositories.length === 1 ? "this repository" : "these repositories"} yet — only your own connections and the team’s service tokens are offered. Connect one, then come back and pick it here.
+ {onOpenProviders && } +
+ )} + + {candidates.length > 0 && ( +
+ {candidates.map(c => ( + + ))} +
+ )} + + {error &&
{error}
} +
+ +
+ +
+
+ , + document.body, + ); +} diff --git a/frontend/src/components/remedy/RepositoryConnectionIssue.tsx b/frontend/src/components/remedy/RepositoryConnectionIssue.tsx new file mode 100644 index 000000000..45079eca0 --- /dev/null +++ b/frontend/src/components/remedy/RepositoryConnectionIssue.tsx @@ -0,0 +1,65 @@ +import { Ic } from "@/_imported/ai-code-space/icons"; +import type { CredentialSummary } from "@/api/types"; +import { useMe } from "@/hooks/use-me"; +import { TeamPermissions, useTeamPermissions } from "@/hooks/use-team-management"; +import type { ConnectionRepository } from "@/hooks/use-repository-connection-issues"; +import { isStaticToken, type ConnectionIssue } from "@/lib/credentialHealth"; + +import { useProviderFixes } from "./useProviderFixes"; + +/** The chip a repository wears when its connection does not work: plain words, amber for something to repair, red once the credential is gone. */ +export function ConnectionIssueChip({ issue }: { issue: ConnectionIssue }) { + const gone = issue.kind === "disconnected" || issue.kind === "retired"; + + return ( + + {issue.label} + + ); +} + +/** + * The way out, on the repository itself: Replace token for a pasted token (the credential's own person, or anyone who manages + * credentials), Reconnect for a sign-in (only who manages credentials: starting an OAuth connect takes `credentials.manage` whoever + * owns the row), and Re-link to another credential (when the caller manages repositories). The same fixes the run page offers, done + * by the same code. The server stays the authority: these only decide which buttons are worth showing, so a person is never offered + * one that could only answer 403. + * + * It stops clicks reaching the row it sits in: a repository row opens the repository, and a dialog's portal still bubbles React events. + */ +export function RepositoryConnectionFix({ repository, issue, onConnectNew }: { repository: ConnectionRepository; issue: ConnectionIssue; onConnectNew?: () => void }) { + const fix = useProviderFixes(); + const me = useMe(); + const { can } = useTeamPermissions(); + const credential: CredentialSummary | undefined = issue.credential; + + const mayRelink = can(TeamPermissions.ReposManage); + const ownsIt = credential?.ownership === "Personal" && credential.ownerUserId != null && credential.ownerUserId === me.data?.id; + const mayRepair = credential != null && (isStaticToken(credential) ? ownsIt || can(TeamPermissions.CredentialsManage) : can(TeamPermissions.CredentialsManage)); + // A credential that merely looks fine (the repository is in Error for another reason) or whose person left is moved off, not repaired. + const repairable = credential != null && (issue.kind === "rejected" || issue.kind === "disconnected"); + + return ( +
e.stopPropagation()}> + {mayRepair && repairable && credential && ( + + )} + {mayRelink && ( + + )} + {!mayRelink && !(mayRepair && repairable) && Ask a team Admin or Owner to fix this connection.} + {onConnectNew && mayRelink && } + e.stopPropagation()}>{fix.dialogs} +
+ ); +} diff --git a/frontend/src/components/remedy/RunRemedyCard.tsx b/frontend/src/components/remedy/RunRemedyCard.tsx new file mode 100644 index 000000000..993174522 --- /dev/null +++ b/frontend/src/components/remedy/RunRemedyCard.tsx @@ -0,0 +1,30 @@ +import { useContext } from "react"; + +import { useReplayRun, useRunRemedy } from "@/hooks/use-workflows"; +import { RunOpenContext } from "@/components/workflows/runOpenContext"; + +import { ProviderRemedyCard } from "./ProviderRemedyCard"; + +/** + * The remediation card for a failed run: asks the backend what is wrong with the provider connection it failed on and what THIS + * person can do, and renders it — or nothing, so the caller shows the ordinary failure it always did (no remedy for this run, the + * read still loading, or it failed). The answer is read fresh and re-read after any fix, so the card turns to "run it again" the + * moment the connection works. + * + * Re-run replays the run and opens the new one, the same Re-run the turn's own footer offers. + */ +export function RunRemedyCard({ runId, rawDetail, onOpenRun }: { runId: string; rawDetail?: string | null; onOpenRun?: (runId: string) => void }) { + const remedy = useRunRemedy(runId); + const replay = useReplayRun(); + const contextOpenRun = useContext(RunOpenContext); + const openRun = onOpenRun ?? contextOpenRun; + + if (!remedy.data) return null; + + const rerun = async () => { + const result = await replay.mutateAsync(runId); + openRun?.(result.runId); + }; + + return void remedy.refetch()} />; +} diff --git a/frontend/src/components/remedy/useProviderFixes.tsx b/frontend/src/components/remedy/useProviderFixes.tsx new file mode 100644 index 000000000..f3f8ad2ca --- /dev/null +++ b/frontend/src/components/remedy/useProviderFixes.tsx @@ -0,0 +1,96 @@ +import { useCallback, useState, type ReactNode } from "react"; + +import { ConnectRemoteModal } from "@/_imported/ai-code-space/connect-remote-modal"; +import { ApiError } from "@/api/request"; +import type { AuthType, ProviderInstanceSummary } from "@/api/types"; +import { useAlert } from "@/components/dialog"; +import { ReplaceTokenModal } from "@/components/identities/ReplaceTokenModal"; +import { useProviderInstances } from "@/hooks/use-credentials"; +import { describeOAuthFlowError, OAuthFlowError, useOAuthFlow } from "@/hooks/use-oauth-flow"; + +import { RelinkDialog, type RelinkRepository } from "./RelinkDialog"; + +/** What a fix needs to know of a credential: which one, what to call it, where it lives. A remedy's credential and a list row both fit. */ +export interface FixCredential { + id: string; + displayName: string; + providerInstanceId: string; + authType: AuthType; +} + +type OpenDialog = + | { kind: "relink"; repositories: RelinkRepository[]; providerInstanceId: string; fromCredentialId: string | null } + | { kind: "replace-token"; credential: FixCredential; instance: ProviderInstanceSummary | undefined } + | { kind: "providers"; editInstanceId?: string; suggestedBaseUrl?: string }; + +/** + * The fixes a provider failure is repaired with — reconnect in place, replace a token, re-link a repository, correct a provider's + * address, open the Providers dialog — wherever the failure is shown: the run page's card, a repository row, a refused delivery. + * One place so each fix does the same thing everywhere and refuses the same way. Nothing here decides WHETHER a fix is offered: the + * backend says that, per person, and the caller renders only what it was told is enabled. + * + * `fix.dialogs` must be rendered once by the caller, next to the buttons: the fixes that need a form (a token, a credential to pick, + * a provider address) open it there. A reconnect needs none — it runs the provider's own sign-in in a popup and the credential keeps + * its id, so everything bound through it comes back at once. + */ +export function useProviderFixes({ onFixed }: { onFixed?: () => void } = {}) { + const instances = useProviderInstances(); + const runOAuth = useOAuthFlow(); + const alert = useAlert(); + const [dialog, setDialog] = useState(null); + const [reconnectingId, setReconnectingId] = useState(null); + + const instanceOf = useCallback((id: string) => instances.data?.find(i => i.id === id), [instances.data]); + + const reconnect = useCallback(async (credential: FixCredential) => { + setReconnectingId(credential.id); + + try { + await runOAuth({ providerInstanceId: credential.providerInstanceId, displayName: credential.displayName, replaceCredentialId: credential.id }); + onFixed?.(); + } catch (err) { + const message = err instanceof OAuthFlowError ? describeOAuthFlowError(err) : err instanceof ApiError || err instanceof Error ? err.message : "Authorization failed."; + await alert({ title: "Couldn't reconnect", message, variant: "error" }); + } finally { + setReconnectingId(null); + } + }, [runOAuth, alert, onFixed]); + + const close = useCallback(() => setDialog(null), []); + + const dialogs: ReactNode = ( + <> + {dialog?.kind === "relink" && ( + setDialog({ kind: "providers" })} + /> + )} + {dialog?.kind === "replace-token" && ( + + )} + {dialog?.kind === "providers" && } + + ); + + return { + /** The credential a reconnect is running for, so its button can say so. */ + reconnectingId, + reconnect, + replaceToken: (credential: FixCredential) => setDialog({ kind: "replace-token", credential, instance: instanceOf(credential.providerInstanceId) }), + relink: (repositories: RelinkRepository[], providerInstanceId: string, fromCredentialId: string | null) => setDialog({ kind: "relink", repositories, providerInstanceId, fromCredentialId }), + /** Open the provider's own edit form, starting from `suggestedBaseUrl` — the address it answered on. */ + editProviderAddress: (providerInstanceId: string, suggestedBaseUrl?: string | null) => setDialog({ kind: "providers", editInstanceId: providerInstanceId, suggestedBaseUrl: suggestedBaseUrl ?? undefined }), + openProviders: () => setDialog({ kind: "providers" }), + dialogs, + }; +} diff --git a/frontend/src/components/repositories/RepositoryWebhooksPanel.test.tsx b/frontend/src/components/repositories/RepositoryWebhooksPanel.test.tsx index 78deaea60..2b77f0d3b 100644 --- a/frontend/src/components/repositories/RepositoryWebhooksPanel.test.tsx +++ b/frontend/src/components/repositories/RepositoryWebhooksPanel.test.tsx @@ -2,7 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { MeResponse, MeTeam, ProviderKind, RejectedDelivery, RepositoryWebhookAttemptDetail, RepositoryWebhookCoverage, RepositoryWebhookDetail } from "@/api/types"; +import type { MeResponse, MeTeam, ProviderKind, ProviderRemedy, RejectedDelivery, RepositoryWebhookAttemptDetail, RepositoryWebhookCoverage, RepositoryWebhookDetail } from "@/api/types"; +import { DialogProvider } from "@/components/dialog"; import { rejectionCopy } from "@/lib/webhookState"; import { RepositoryWebhooksPanel } from "./RepositoryWebhooksPanel"; @@ -78,7 +79,7 @@ describe("repository webhooks panel", () => { })); } - function renderPanel(hooks: RepositoryWebhookDetail[], { provider = "GitLab" as ProviderKind, permissions = ["repos.manage"], secret = "s3cr3t-from-the-server", refusals = [] as RejectedDelivery[], cap = 50, coverage = { scope: "Repository", ownerPath: null, hook: null } as RepositoryWebhookCoverage } = {}) { + function renderPanel(hooks: RepositoryWebhookDetail[], { provider = "GitLab" as ProviderKind, permissions = ["repos.manage"], secret = "s3cr3t-from-the-server", refusals = [] as RejectedDelivery[], cap = 50, remedy = null as ProviderRemedy | null, coverage = { scope: "Repository", ownerPath: null, hook: null } as RepositoryWebhookCoverage } = {}) { localStorage.setItem("codespace.jwt", "test-jwt"); localStorage.setItem("codespace.activeTeamId", "t1"); @@ -86,7 +87,7 @@ describe("repository webhooks panel", () => { "/webhooks/coverage": coverage, "/webhooks/w1/secret": { webhookId: "w1", secret }, "/webhooks": hooks, - "/rejected-deliveries": { deliveries: refusals, cap }, + "/rejected-deliveries": { deliveries: refusals, cap, ...(remedy ? { remedy } : {}) }, "/api/users/me": me(team(permissions)), }); @@ -94,7 +95,9 @@ describe("repository webhooks panel", () => { return render( - + + + , ); } @@ -594,4 +597,51 @@ describe("repository webhooks panel", () => { expect(screen.getByText(/GitHub sends an id with every delivery/)).toBeTruthy(); expect(screen.queryByText("The signature did not match")).toBeNull(); }); + + // ── Refusals that blame the repository's CREDENTIAL carry the way out ── + + const credentialRemedy = (code: string): ProviderRemedy => ({ + code, + title: "GitLab refused the credential this trigger uses", + cause: "GitLab refused “GitLab · nacho”, so CodeSpace could not check who wrote the pull request on ruhappy/ruhappy-web. The author is not in question — the credential is.", + resolved: false, + credential: { id: "c1", displayName: "GitLab · nacho", ownership: "Personal", ownerUserId: "u1", ownerName: "Mars P", authType: "OAuth", status: "Error", health: "rejected", retired: false, providerInstanceId: "pi1" }, + repositories: [{ id: "r1", fullPath: "ruhappy/ruhappy-web", status: "Active", credentialId: "c1", credentialWorks: false }], + providerInstance: { id: "pi1", displayName: "GitLab", provider: "GitLab", baseUrl: "https://gitlab.test" }, + actions: [{ kind: "reconnect", label: "Reconnect GitLab", enabled: true }, { kind: "relink", label: "Re-link repository", enabled: true }, { kind: "open_providers", label: "Open Providers", enabled: true }], + askPeople: [], + }); + + it.each([ + ["credential_rejected", "The repository's credential was refused", /The author is not in question — the credential is/], + ["author_standing_unknown", "Who wrote the pull request could not be read", /Setting “Pull requests from” to Anyone is not the fix/], + ] as const)("reads a %s refusal as its own news — not as an unrecognised reason, and not as an author who is not a member", async (reason, headline, remedyText) => { + renderPanel([hook()], { refusals: [refusal({ reason, detail: "GitLab answered HTTP 401 to the repository's credential 'GitLab · nacho'", verificationResultJson: null })], remedy: credentialRemedy(reason) }); + + await waitFor(() => expect(screen.getByText(headline)).toBeTruthy()); + + expect(toneOf(headline)).toBe("bad"); + expect(screen.getAllByText(remedyText).length).toBeGreaterThan(0); + expect(screen.queryByText("Refused on arrival")).toBeNull(); + expect(screen.queryByText("A pull request from someone without a role on the repository")).toBeNull(); + }); + + it("puts the credential's remediation card above the refusals once, with the fixes the backend says are the caller's", async () => { + renderPanel([hook()], { refusals: [refusal({ id: "d1", reason: "credential_rejected", detail: "x" }), refusal({ id: "d2", reason: "credential_rejected", detail: "y" })], remedy: credentialRemedy("credential_rejected") }); + + await waitFor(() => expect(screen.getByRole("alert", { name: "Connection problem" })).toBeTruthy()); + + expect(screen.getAllByRole("alert", { name: "Connection problem" })).toHaveLength(1); + expect(screen.getByRole("button", { name: "Reconnect GitLab" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Re-link repository" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Re-run" })).toBeNull(); // a refused delivery started no run + }); + + it("shows no remediation card for refusals that blame nothing about the credential", async () => { + renderPanel([hook()], { refusals: [refusal({ reason: "signature_invalid" })] }); + + await waitFor(() => expect(screen.getByText(rejectionCopy("signature_invalid", "GitLab").headline)).toBeTruthy()); + + expect(screen.queryByRole("alert", { name: "Connection problem" })).toBeNull(); + }); }); diff --git a/frontend/src/components/repositories/RepositoryWebhooksPanel.tsx b/frontend/src/components/repositories/RepositoryWebhooksPanel.tsx index 12c699881..ba9a3af5f 100644 --- a/frontend/src/components/repositories/RepositoryWebhooksPanel.tsx +++ b/frontend/src/components/repositories/RepositoryWebhooksPanel.tsx @@ -7,6 +7,7 @@ import { useRepositoryRejectedDeliveries, useRepositoryWebhookCoverage, useRepos import { TeamPermissions, useTeamPermissions } from "@/hooks/use-team-management"; import { relativeTime } from "@/lib/codeTree"; import { attemptOutcome, canRetryWebhook, connectionCoverageNote, rejectedDeliveriesNote, rejectionCopy, UNPLACED_DELIVERY_NOTE, webhookDiagnosis, webhookSetupSteps, webhookState, type WebhookSetupStep } from "@/lib/webhookState"; +import { ProviderRemedyCard } from "@/components/remedy/ProviderRemedyCard"; /** * Repository → Webhook. What the repository's hooks are doing, why they are not, and how to finish @@ -169,6 +170,10 @@ function RejectedDeliveries({ repositoryId, provider }: { repositoryId: string;
{rejectedDeliveriesNote(answer.deliveries.length, answer.cap)}
+ {/* What to do about the refusals that blame the credential — once, above the rows that carry its reasons, because it is about the + credential the repository runs through now and not about any one delivery. Absent when none of them blames it. */} + {answer.remedy &&
} +
{answer.deliveries.map((delivery) => )}
diff --git a/frontend/src/components/repository-list.test.tsx b/frontend/src/components/repository-list.test.tsx index f2ef0a831..e7f1c6775 100644 --- a/frontend/src/components/repository-list.test.tsx +++ b/frontend/src/components/repository-list.test.tsx @@ -33,15 +33,17 @@ describe("repository list controls", () => { repo({ id: "r2", fullPath: "acme/web", name: "web", status: "Error", lastError: "credential revoked" }), ]; + // c1 is what both repositories run through (a working credential: the broken one is broken by its STATUS, not its credential); c2 is a spare. const credentials = [ + { id: "c1", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u2", ownerUserName: "Ada", ownership: "Personal", authType: "Pat", displayName: "Ada's token", status: "Active", createdDate: "2026-01-01T00:00:00Z" }, { id: "c2", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u1", ownerUserName: "Maya", ownership: "Personal", authType: "Pat", displayName: "Maya's token", status: "Active", createdDate: "2026-01-01T00:00:00Z" }, ]; - function renderAs(t: MeTeam) { + function renderAs(t: MeTeam, over: { repositories?: RepositorySummary[]; credentials?: unknown[] } = {}) { localStorage.setItem("codespace.jwt", "test-jwt"); localStorage.setItem("codespace.activeTeamId", t.id); - const routes: Record = { "/api/users/me": me(t), "/api/repositories": repositories, "/api/provider-instances": [instance], "/api/credentials": credentials }; + const routes: Record = { "/api/users/me": me(t), "/api/repositories": over.repositories ?? repositories, "/api/provider-instances": [instance], "/api/credentials": over.credentials ?? credentials }; vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); @@ -86,4 +88,70 @@ describe("repository list controls", () => { expect(screen.getByRole("button", { name: /Re-link credential/ })).toBeTruthy(); expect(screen.getByRole("button", { name: /Add repository/ })).toBeTruthy(); }); + + // ── Connection health: the chip belongs on EVERY repository whose credential is not healthy, not only the ones flipped to Error ── + + const admin = () => team("Admin", ["repos.manage", "credentials.manage", "members.manage"]); + const credential = (id: string, over: Record = {}) => ({ ...credentials[1], id, displayName: `cred ${id}`, ...over }); + const onCredential = (id: string | null, over: Partial = {}) => repo({ id: `r-${id ?? "none"}`, fullPath: `acme/${id ?? "none"}`, name: String(id ?? "none"), credentialId: id, ...over }); + + it("flags a repository still marked Active whose credential was refused, disconnected or is gone", async () => { + renderAs(admin(), { + credentials: [credential("rejected", { status: "Error", authType: "OAuth", lastError: "invalid_grant" }), credential("gone", { status: "Revoked" }), credential("left", { health: "owner_left" }), credential("fine")], + repositories: [onCredential("rejected"), onCredential("gone"), onCredential("left"), onCredential("fine"), onCredential("retired")], // "retired" is in no list: soft-deleted + }); + + await waitFor(() => expect(screen.getByText("acme/retired")).toBeTruthy()); + + const chips = Array.from(document.querySelectorAll("[data-issue]:not(tr)")).map((e) => [e.getAttribute("data-issue"), e.textContent?.trim()]); + expect(chips).toEqual([["rejected", "needs reconnect"], ["disconnected", "credential disconnected"], ["owner_left", "owner left"], ["retired", "credential disconnected"]]); + expect(screen.getByText("4 repositories need a credential")).toBeTruthy(); + }); + + it("says nothing about a repository whose credential works, and a credential list that has not arrived says nothing either", async () => { + renderAs(admin(), { credentials: [credential("fine")], repositories: [onCredential("fine")] }); + + await waitFor(() => expect(screen.getByText("acme/fine")).toBeTruthy()); + + expect(document.querySelector("[data-issue]")).toBeNull(); + expect(screen.queryByText(/need a credential/)).toBeNull(); + }); + + it("offers the fix on the repository: reconnect the credential, and re-link to another", async () => { + renderAs(admin(), { credentials: [credential("rejected", { status: "Error", authType: "OAuth" }), credential("spare")], repositories: [onCredential("rejected")] }); + + await waitFor(() => expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy()); + + expect(screen.getByRole("button", { name: /Re-link credential/ })).toBeTruthy(); + }); + + it("offers a pasted token a Replace token, not a Reconnect", async () => { + renderAs(admin(), { credentials: [credential("rejected", { status: "Error", authType: "Pat" })], repositories: [onCredential("rejected")] }); + + await waitFor(() => expect(screen.getByRole("button", { name: "Replace token" })).toBeTruthy()); + + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + }); + + it("offers a member who owns a refused pasted token its Replace token but not the re-link, and tells another member who to ask", async () => { + const mine = credential("mine", { status: "Error", authType: "Pat", ownerUserId: "u1" }); + const theirs = credential("theirs", { status: "Error", authType: "Pat", ownerUserId: "u2", ownerUserName: "Ada" }); + renderAs(team("Member", ["workflows.write"]), { credentials: [mine, theirs], repositories: [onCredential("mine"), onCredential("theirs")] }); + + await waitFor(() => expect(screen.getByText("acme/theirs")).toBeTruthy()); + + expect(screen.getAllByRole("button", { name: "Replace token" })).toHaveLength(1); // only the one that is theirs: PUT /credentials/{id}/token passes an owner on membership + expect(screen.queryByRole("button", { name: /Re-link credential/ })).toBeNull(); + expect(screen.getByText("Ask a team Admin or Owner to fix this connection.")).toBeTruthy(); + }); + + it("offers a member no Reconnect on their own refused sign-in, because starting an OAuth connect takes credentials.manage and would answer 403", async () => { + const mine = credential("mine", { status: "Error", authType: "OAuth", ownerUserId: "u1" }); + renderAs(team("Member", ["workflows.write"]), { credentials: [mine], repositories: [onCredential("mine")] }); + + await waitFor(() => expect(screen.getByText("acme/mine")).toBeTruthy()); + + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + expect(screen.getByText("Ask a team Admin or Owner to fix this connection.")).toBeTruthy(); + }); }); diff --git a/frontend/src/components/sessions/SessionRoomView.provider-remedy.test.tsx b/frontend/src/components/sessions/SessionRoomView.provider-remedy.test.tsx new file mode 100644 index 000000000..9cc7530cc --- /dev/null +++ b/frontend/src/components/sessions/SessionRoomView.provider-remedy.test.tsx @@ -0,0 +1,120 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { DiagnosticBlock } from "@/api/sessions"; +import type { ProviderRemedy } from "@/api/types"; +import { DialogProvider } from "@/components/dialog"; +import { RunActionsContext } from "@/components/workflows/runActionsContext"; +import { ErrorCard } from "./SessionRoomView"; + +/** + * A run that failed on a provider connection (a credential disconnected, refused, a redirecting address) carries the failure's code on + * its diagnostic. The Room's turn is cached and shared, so it holds only the CODE; what to do about it, and who may, is read live + * for whoever is looking — and the diagnostic is the whole story until that answer arrives, or when there is none. + */ +const RUN = "11111111-1111-1111-1111-111111111111"; + +const diag = (over: Partial = {}): DiagnosticBlock => ({ + id: "turn-1:diagnostic", seq: 5, type: "diagnostic", tone: "Error", + title: "The credential this run used was disconnected", + text: "The credential 'GitLab · NACHO.L' was disconnected, so nothing bound through it can authenticate.", + actions: [], rawDetail: "Node 'list' failed.", failureCode: "credential_disconnected", ...over, +}); + +const remedy: ProviderRemedy = { + code: "credential_disconnected", + title: "The credential this run used was disconnected", + cause: "“GitLab · NACHO.L” was disconnected, so acme/api cannot authenticate with GitLab. Reconnect it to put it back, or link acme/api to a credential that works.", + resolved: false, + credential: { id: "c1", displayName: "GitLab · NACHO.L", ownership: "Personal", ownerUserId: "u9", ownerName: "Nacho", authType: "OAuth", status: "Revoked", health: "revoked", retired: false, providerInstanceId: "pi1" }, + repositories: [{ id: "r1", fullPath: "acme/api", status: "Error", credentialId: "c1", credentialWorks: false }], + providerInstance: { id: "pi1", displayName: "Acme GitLab", provider: "GitLab", baseUrl: "http://gitlab.test" }, + actions: [ + { kind: "reconnect", label: "Reconnect GitLab", enabled: false, disabledReason: "Only Nacho, or a team Admin, can do this." }, + { kind: "relink", label: "Re-link repository", enabled: false, disabledReason: "Needs the Admin or Owner role." }, + { kind: "open_providers", label: "Open Providers", enabled: true }, + { kind: "rerun", label: "Re-run", enabled: false, disabledReason: "Fix the connection first, then run it again." }, + ], + askPeople: [{ userId: "u9", name: "Nacho", role: "Owner of the credential" }, { userId: "u3", name: "Olive", role: "Owner" }], +}; + +function renderCard(block: DiagnosticBlock, answer: { status: number; body?: unknown }, withRun = true) { + localStorage.setItem("codespace.jwt", "test-jwt"); + localStorage.setItem("codespace.activeTeamId", "t1"); + + const urls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + urls.push(url); + + return url.includes("/remedy") ? new Response(answer.body === undefined ? null : JSON.stringify(answer.body), { status: answer.status, headers: { "Content-Type": "application/json" } }) : new Response("[]", { status: 200, headers: { "Content-Type": "application/json" } }); + })); + + render( + + + + + + + , + ); + + return urls; +} + +afterEach(() => { localStorage.clear(); vi.unstubAllGlobals(); }); + +describe("the room's diagnostic for a provider-connection failure", () => { + it("shows the failure's own words first, then the remediation card once the live remedy arrives", async () => { + renderCard(diag(), { status: 200, body: remedy }); + + expect(screen.getByText(/was disconnected, so nothing bound through it can authenticate/)).toBeTruthy(); // the diagnostic, before anything is known + + await waitFor(() => expect(screen.getByRole("alert", { name: "Connection problem" })).toBeTruthy()); + + expect(screen.getByText(/so acme\/api cannot authenticate with GitLab/)).toBeTruthy(); + expect(screen.getByRole("note").textContent).toContain("Ask Nacho (Owner of the credential) or Olive (Owner)."); + expect(screen.queryByText(/Fix credentials/)).toBeNull(); + }); + + it("asks for the remedy of THIS turn's run", async () => { + const urls = renderCard(diag(), { status: 200, body: remedy }); + + await waitFor(() => expect(urls.some((u) => u.includes(`/api/workflows/runs/${RUN}/remedy`))).toBe(true)); + }); + + it("keeps the ordinary diagnostic when the backend has no remedy for the run", async () => { + const urls = renderCard(diag(), { status: 204 }); + + await waitFor(() => expect(urls.some((u) => u.includes("/remedy"))).toBe(true)); + + expect(screen.queryByRole("alert", { name: "Connection problem" })).toBeNull(); + expect(screen.getByText("The credential this run used was disconnected")).toBeTruthy(); + expect(screen.getByText(/nothing bound through it can authenticate/)).toBeTruthy(); + }); + + it("keeps the ordinary diagnostic when the remedy cannot be read", async () => { + const urls = renderCard(diag(), { status: 500, body: { code: "internal_error", message: "boom" } }); + + await waitFor(() => expect(urls.some((u) => u.includes("/remedy"))).toBe(true)); + + expect(screen.queryByRole("alert", { name: "Connection problem" })).toBeNull(); + expect(screen.getByText(/nothing bound through it can authenticate/)).toBeTruthy(); + }); + + it("never asks for a remedy for a diagnostic that carries no failure code — a run recorded before they were kept reads as it did", () => { + const urls = renderCard(diag({ failureCode: null, title: "Authentication failed" }), { status: 200, body: remedy }); + + expect(screen.getByText("Authentication failed")).toBeTruthy(); + expect(urls.filter((u) => u.includes("/remedy"))).toEqual([]); + }); + + it("never asks without a run to ask about", () => { + const urls = renderCard(diag(), { status: 200, body: remedy }, false); + + expect(urls.filter((u) => u.includes("/remedy"))).toEqual([]); + expect(screen.getByText(/nothing bound through it can authenticate/)).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/sessions/SessionRoomView.tsx b/frontend/src/components/sessions/SessionRoomView.tsx index 253aca20d..aa4041eb5 100644 --- a/frontend/src/components/sessions/SessionRoomView.tsx +++ b/frontend/src/components/sessions/SessionRoomView.tsx @@ -58,7 +58,8 @@ import { formatTokens } from "@/components/workflows/runActivity"; import { planAgentStatus, planDepsLabel, planStateIcon, planStateTone, planStateWord, composePlanFeedback } from "@/lib/planChecklist"; import { useConfirm } from "@/components/dialog"; import { LaunchTaskModal } from "@/components/tasks/LaunchTaskModal"; -import { isRunActive, useCancelRun, useContinueRun, useOpenPullRequest, usePendingDecisions, useReplayRun } from "@/hooks/use-workflows"; +import { isRunActive, useCancelRun, useContinueRun, useOpenPullRequest, usePendingDecisions, useReplayRun, useRunRemedy } from "@/hooks/use-workflows"; +import { RunRemedyCard } from "@/components/remedy/RunRemedyCard"; import { statusWord } from "@/lib/runStatus"; import { shortRunTitle } from "@/lib/runTitle"; import { useRunRoomStream } from "@/hooks/use-run-room-stream"; @@ -2121,6 +2122,25 @@ function formatBytes(bytes: number): string { * error red, which would tell an operator their run broke when it is simply waiting on them. */ export function ErrorCard({ diag, onOpenTrace }: { diag: DiagnosticBlock; onOpenTrace?: () => void }) { + const run = useContext(RunActionsContext); + + // A failure the run recorded as a provider connection problem carries its code; the card that says what to do about it — and who + // may — is read live for whoever is looking. A diagnostic with no code never mounts the query, so it renders as it always did. + return diag.failureCode && run ? : ; +} + +/** + * A provider-connection failure: the remediation card when the backend has a remedy for the person looking, and the ordinary + * diagnostic (the failure's own words) until it does — or when it has none, or cannot be read. The remedy never replaces the + * failure; it adds the way out. + */ +function DiagnosticWithRemedy({ diag, runId, onOpenTrace }: { diag: DiagnosticBlock; runId: string; onOpenTrace?: () => void }) { + const remedy = useRunRemedy(runId); + + return remedy.data ? : ; +} + +function PlainDiagnostic({ diag, onOpenTrace }: { diag: DiagnosticBlock; onOpenTrace?: () => void }) { const [showRaw, setShowRaw] = useState(false); const actions = diag.actions ?? []; const attention = diag.tone === "Info"; diff --git a/frontend/src/components/workflows/RunDetailView.test.tsx b/frontend/src/components/workflows/RunDetailView.test.tsx index 54eed95fd..9197b12bc 100644 --- a/frontend/src/components/workflows/RunDetailView.test.tsx +++ b/frontend/src/components/workflows/RunDetailView.test.tsx @@ -60,6 +60,11 @@ vi.mock("@/hooks/use-team-members", () => ({ useTeamMemberIdentityMap: () => new Map(), })); +// The card asks the backend what to do about a provider-connection failure; its own tests cover what it renders. Here only WHEN the view mounts it. +vi.mock("@/components/remedy/RunRemedyCard", () => ({ + RunRemedyCard: ({ runId }: { runId: string }) =>
, +})); + vi.mock("./GovernedToolsPanel", () => ({ GovernedToolsPanel: ({ runId, active }: { runId: string; active: boolean }) => { governedToolsPanelMock({ runId, active }); @@ -530,3 +535,23 @@ describe("RunDetailView — Live-work center", () => { expect(screen.queryByText("Node execution")).not.toBeInTheDocument(); // …so the trace can't show-then-collapse }); }); + +describe("RunDetailView — the remedy for a provider-connection failure", () => { + it("mounts the remediation card for a run that failed, so a connection problem says what to do", () => { + useWorkflowRunViewMetadataMock.mockReturnValue({ isLoading: false, error: null, data: viewMetadata({ status: "Failure", hasError: true }) }); + useWorkflowRunMock.mockImplementation(() => ok(detail({ status: "Failure", error: "Node 'list' failed." }))); + + render(); + + expect(screen.getByTestId("run-remedy-card").getAttribute("data-run-id")).toBe("parent-1"); + expect(screen.getByText("Run failed")).toBeTruthy(); // the failure is still told; the card adds the way out + }); + + it("mounts nothing for a run that did not fail", () => { + useWorkflowRunMock.mockImplementation(() => ok(detail({}))); + + render(); + + expect(screen.queryByTestId("run-remedy-card")).toBeNull(); + }); +}); diff --git a/frontend/src/components/workflows/RunDetailView.tsx b/frontend/src/components/workflows/RunDetailView.tsx index 3b048526d..f1270176e 100644 --- a/frontend/src/components/workflows/RunDetailView.tsx +++ b/frontend/src/components/workflows/RunDetailView.tsx @@ -23,6 +23,7 @@ import { RunStatusBadge } from "./RunStatusBadge"; import { RunTrace } from "./RunTrace"; import { groupMapBranches, nodeIterationLabel, type MapRollup } from "./mapBranches"; import { concurrentNodeKeys, runNodeKey } from "./runConcurrency"; +import { RunRemedyCard } from "@/components/remedy/RunRemedyCard"; export { RunStatusBadge }; @@ -123,6 +124,9 @@ function ActivityRunDetailView({ runId, nested, depth, onOpenRun, view, setView,
) : } + {/* A failure on a provider connection says what to do about it, and who may — read live for whoever is looking. It renders nothing + for any other failure, so the banner below stays the whole story, as it always was. */} + {r.hasError && } {r.hasError &&
Run failed
Expand Run input & output to read the recorded run-level error.
} {suspended && pendingWait.isLoading &&
Loading pending action…
} {suspended && pendingWait.error &&
Pending action unavailable
The full wait payload was not used as a fallback.
} diff --git a/frontend/src/hooks/use-credentials.ts b/frontend/src/hooks/use-credentials.ts index e86ea6bc2..50235c741 100644 --- a/frontend/src/hooks/use-credentials.ts +++ b/frontend/src/hooks/use-credentials.ts @@ -276,10 +276,25 @@ export function useReplaceCredentialToken() { queryClient.invalidateQueries({ queryKey: ["credentials"] }); queryClient.invalidateQueries({ queryKey: ["repositories"] }); queryClient.invalidateQueries({ queryKey: ["credential-capabilities"] }); + queryClient.invalidateQueries({ queryKey: ["run-remedy"] }); + queryClient.invalidateQueries({ queryKey: ["repository"] }); // a repository's refused deliveries carry a remedy of their own }, }); } +/** + * The credentials the caller could move a credential's bindings onto, best first. Asked only when a fix is being picked + * (`enabled`), never on page load: it is a read per credential. + */ +export function useRelinkSuggestions(credentialId: string | null, enabled = true) { + return useQuery({ + queryKey: ["relink-suggestions", credentialId], + queryFn: () => oauthApi.getRelinkSuggestions(credentialId!), + enabled: enabled && credentialId != null, + staleTime: 0, + }); +} + /** Backend-driven defaults for the "Add provider" form. Cached aggressively — the source * is a static IProviderModule, so the values change only on a backend deploy. */ export function useProviderDefaults(provider: ProviderKind | null) { diff --git a/frontend/src/hooks/use-oauth-flow.ts b/frontend/src/hooks/use-oauth-flow.ts index 7418ead98..68ed4eb3a 100644 --- a/frontend/src/hooks/use-oauth-flow.ts +++ b/frontend/src/hooks/use-oauth-flow.ts @@ -53,6 +53,18 @@ export class OAuthFlowError extends Error { } } +/** What a failed connect says to the person who started it — shared by every screen that starts one (the Providers dialog, a remediation card). */ +export function describeOAuthFlowError(err: OAuthFlowError): string { + switch (err.code) { + case "popup_blocked": return "Your browser blocked the OAuth popup. Allow popups for this site and try again."; + case "cancelled": return "You closed the authorization window. No connection was made."; + case "init_failed": return `Couldn't start the OAuth flow: ${err.message}`; + case "provider_error": return err.providerError ? `Provider rejected: ${err.providerError}` : err.message; + case "timeout": return "The authorization took too long. Try again."; + default: return err.message; + } +} + interface RunInput extends Omit { /** Optional override; defaults to the in-app `/oauth-callback` route on this page's origin (see `oauthReturnUrlsFor`). */ returnUrl?: string; @@ -119,6 +131,9 @@ export function useOAuthFlow() { // open modal / sidebar showing connection status re-fetches immediately rather than waiting for the next mount. await queryClient.invalidateQueries({ queryKey: ["credentials"] }); await queryClient.invalidateQueries({ queryKey: ["repositories"] }); + // A run page that offered this reconnect re-reads its remedy, so the card turns to "run it again" instead of waiting for a focus change. + await queryClient.invalidateQueries({ queryKey: ["run-remedy"] }); + await queryClient.invalidateQueries({ queryKey: ["repository"] }); return outcome; } finally { try { if (!popup.closed) popup.close(); } catch { /* cross-origin during provider visit; harmless */ } diff --git a/frontend/src/hooks/use-repositories.ts b/frontend/src/hooks/use-repositories.ts index 4dbe251aa..c277636d2 100644 --- a/frontend/src/hooks/use-repositories.ts +++ b/frontend/src/hooks/use-repositories.ts @@ -327,6 +327,8 @@ export function useRelinkRepositoryCredential() { // the detail view need a refresh so the status badge clears immediately. queryClient.invalidateQueries({ queryKey: ["repositories"] }); queryClient.invalidateQueries({ queryKey: ["repository"] }); + // A run that failed on this repository's connection reads "fixed" once the repository runs through a live one. + queryClient.invalidateQueries({ queryKey: ["run-remedy"] }); }, }); } diff --git a/frontend/src/hooks/use-repository-connection-issues.ts b/frontend/src/hooks/use-repository-connection-issues.ts new file mode 100644 index 000000000..f62862f7d --- /dev/null +++ b/frontend/src/hooks/use-repository-connection-issues.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; + +import type { RepositorySummary } from "@/api/types"; +import { useCredentials } from "@/hooks/use-credentials"; +import { repositoryConnectionIssue, type ConnectionIssue } from "@/lib/credentialHealth"; + +/** What a repository row needs to say whether its connection works. A summary and a detail both fit. */ +export type ConnectionRepository = Pick; + +/** + * Whether a repository's connection works, joined to the credential list — and the credential list's own loading state, so a + * list that has not arrived yet never reads as a credential that is gone. One read shared by every screen that shows a repository. + */ +export function useRepositoryConnectionIssues() { + const credentials = useCredentials(); + const byId = useMemo(() => new Map((credentials.data ?? []).map(c => [c.id, c] as const)), [credentials.data]); + const loaded = credentials.data !== undefined; + + return { + issueOf: (repository: ConnectionRepository): ConnectionIssue | null => repositoryConnectionIssue(repository, byId, loaded), + credentialById: byId, + loaded, + }; +} diff --git a/frontend/src/hooks/use-workflows.ts b/frontend/src/hooks/use-workflows.ts index e09022095..7adb72e8e 100644 --- a/frontend/src/hooks/use-workflows.ts +++ b/frontend/src/hooks/use-workflows.ts @@ -203,6 +203,24 @@ export function useWorkflowRunPendingWait(runId: string | null, enabled = true) export const WORKFLOW_RUN_IDENTITY_POLL_MS = 2000; +/** + * What is wrong with the provider connection a run failed on, and what THIS user can do about it. Read fresh — never cached + * with the page that shows it: it depends on who is looking and on what has been fixed since, so it re-asks when the window + * regains focus (a reconnect happens in a popup or another tab) and after any fix the card itself performs. + */ +export function useRunRemedy(runId: string | null, enabled = true) { + return useQuery({ + queryKey: ["run-remedy", runId], + queryFn: () => workflowsApi.getRunRemedy(runId!), + enabled: enabled && runId != null, + refetchOnWindowFocus: true, + staleTime: 0, + // The card is the way out of a failure, not the failure itself: an API that has no such read (a rolling deploy, a rollback) must not + // be asked three times for every failed run a person opens. Focus asks again. + retry: false, + }); +} + /** Bounded canonical route lookup. Full run detail remains owned by the session-less detail fallback. */ export function useWorkflowRunIdentity(ref: string | null, enabled = true) { return useQuery({ diff --git a/frontend/src/lib/credentialHealth.test.ts b/frontend/src/lib/credentialHealth.test.ts new file mode 100644 index 000000000..f3632a0cb --- /dev/null +++ b/frontend/src/lib/credentialHealth.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import type { CredentialSummary, RepositorySummary } from "@/api/types"; +import { credentialHealthWord, isLiveCredential, isStaticToken, repositoryConnectionIssue } from "./credentialHealth"; + +const credential = (over: Partial = {}): CredentialSummary => ({ + id: "c1", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u1", ownership: "Personal", authType: "OAuth", displayName: "GitLab · nacho", status: "Active", createdDate: "2026-01-01T00:00:00Z", ...over, +}); + +const repository = (over: Partial> = {}) => ({ credentialId: "c1" as string | null, status: "Active" as RepositorySummary["status"], lastError: null as string | null, ...over }); + +const byId = (...credentials: CredentialSummary[]) => new Map(credentials.map((c) => [c.id, c] as const)); + +describe("credentialHealthWord", () => { + it.each([ + [{ status: "Active" }, "active"], + [{ status: "Active", health: "active" }, "active"], + [{ status: "Active", health: "expiring" }, "expiring"], + [{ status: "Active", health: "rejected" }, "rejected"], // a token whose recorded expiry passed reads rejected before anything has marked the row + [{ status: "Error" }, "rejected"], // a server that sends no health: status is what there is + [{ status: "Expired" }, "rejected"], + [{ status: "Revoked" }, "disconnected"], + [{ status: "Active", health: "revoked" }, "disconnected"], + [{ status: "Active", health: "owner_left" }, "owner_left"], + [{ status: "Revoked", health: "owner_left" }, "disconnected"], // disconnected beats everything: nothing is left to be healthy + ] as const)("reads %j as %s", (over, word) => { + expect(credentialHealthWord(credential(over as Partial))).toBe(word); + }); + + it("calls only an active or expiring credential live", () => { + expect(isLiveCredential(credential())).toBe(true); + expect(isLiveCredential(credential({ health: "expiring" }))).toBe(true); + for (const over of [{ status: "Error" }, { status: "Revoked" }, { health: "rejected" }, { health: "owner_left" }] as const) expect(isLiveCredential(credential(over as Partial))).toBe(false); + }); + + it("tells a pasted token from a sign-in", () => { + expect(isStaticToken({ authType: "Pat" })).toBe(true); + expect(isStaticToken({ authType: "ProjectAccessToken" })).toBe(true); + expect(isStaticToken({ authType: "GroupAccessToken" })).toBe(true); + expect(isStaticToken({ authType: "OAuth" })).toBe(false); + }); +}); + +describe("repositoryConnectionIssue — joined by credentialId, not only status Error", () => { + it("is nothing for a repository whose credential works", () => { + expect(repositoryConnectionIssue(repository(), byId(credential()), true)).toBeNull(); + }); + + it("flags a repository still marked Active whose credential was refused, with the right words for a sign-in and for a token", () => { + expect(repositoryConnectionIssue(repository(), byId(credential({ status: "Error" })), true)).toMatchObject({ kind: "rejected", label: "needs reconnect" }); + expect(repositoryConnectionIssue(repository(), byId(credential({ status: "Error", authType: "Pat" })), true)).toMatchObject({ kind: "rejected", label: "needs a new token" }); + }); + + it("flags a repository whose credential was disconnected", () => { + expect(repositoryConnectionIssue(repository(), byId(credential({ status: "Revoked" })), true)).toMatchObject({ kind: "disconnected", label: "credential disconnected" }); + }); + + it("flags a repository whose credential's person left the team", () => { + expect(repositoryConnectionIssue(repository(), byId(credential({ health: "owner_left" })), true)).toMatchObject({ kind: "owner_left", label: "owner left" }); + }); + + it("treats a credential the list no longer holds — retired, soft-deleted — as disconnected, once the list has arrived", () => { + expect(repositoryConnectionIssue(repository(), byId(), true)).toMatchObject({ kind: "retired", label: "credential disconnected" }); + }); + + it("says nothing about a missing credential until the list has loaded: absent from a list not yet read is not absent", () => { + expect(repositoryConnectionIssue(repository(), byId(), false)).toBeNull(); + expect(repositoryConnectionIssue(repository({ status: "Error", lastError: "boom" }), byId(), false)).toMatchObject({ kind: "error", title: "boom" }); + }); + + it("keeps the old reading for a repository in Error whose credential looks fine, or that has none", () => { + expect(repositoryConnectionIssue(repository({ status: "Error" }), byId(credential()), true)).toMatchObject({ kind: "error", label: "needs new credential" }); + expect(repositoryConnectionIssue(repository({ status: "Error", credentialId: null }), byId(), true)).toMatchObject({ kind: "unbound", label: "needs new credential" }); + expect(repositoryConnectionIssue(repository({ credentialId: null }), byId(), true)).toBeNull(); // never bound is not broken + }); + + it("leaves an archived (paused) repository alone", () => { + expect(repositoryConnectionIssue(repository({ status: "Paused" }), byId(credential({ status: "Revoked" })), true)).toBeNull(); + }); + + it("hands back the credential the row runs through, so the fix can act on it", () => { + const dead = credential({ status: "Error" }); + + expect(repositoryConnectionIssue(repository(), byId(dead), true)?.credential).toBe(dead); + }); + + it("never carries a token into the words it says", () => { + const issue = repositoryConnectionIssue(repository(), byId(credential({ status: "Error", lastError: "needs reconnect: invalid_grant" })), true)!; + + expect(`${issue.label} ${issue.title}`).not.toMatch(/glpat|Bearer|access_token/i); + }); +}); diff --git a/frontend/src/lib/credentialHealth.ts b/frontend/src/lib/credentialHealth.ts new file mode 100644 index 000000000..3781ef331 --- /dev/null +++ b/frontend/src/lib/credentialHealth.ts @@ -0,0 +1,79 @@ +import type { CredentialSummary, RepositorySummary } from "@/api/types"; + +/** + * What a credential's health says and what a repository running through it needs — the one place the lists, the Providers dialog + * and the repository rows read it, so a credential never reads one way in one screen and another in the next. + * + * `health` comes from the backend and is derived there from the row (never stored); a server that predates it sends none, which is + * why `status` is read alongside it: `Error` is the provider's refusal and `Revoked` is a disconnect, health or no health. + */ + +export type CredentialHealthWord = "active" | "expiring" | "rejected" | "disconnected" | "owner_left"; + +/** The health word a credential's row says, folding `status` in for a server that sends no `health`. */ +export function credentialHealthWord(credential: CredentialSummary): CredentialHealthWord { + if (credential.status === "Revoked" || credential.health === "revoked") return "disconnected"; + if (credential.status === "Error" || credential.status === "Expired" || credential.health === "rejected") return "rejected"; + if (credential.health === "owner_left") return "owner_left"; + if (credential.health === "expiring") return "expiring"; + + return "active"; +} + +/** The credential can authenticate as far as the row can tell: neither refused, disconnected nor lent by someone who left. */ +export function isLiveCredential(credential: CredentialSummary): boolean { + const word = credentialHealthWord(credential); + + return word === "active" || word === "expiring"; +} + +/** A pasted token (personal, project or group) rather than a sign-in — decides Replace token versus Reconnect. */ +export function isStaticToken(credential: Pick): boolean { + return credential.authType === "Pat" || credential.authType === "ProjectAccessToken" || credential.authType === "GroupAccessToken"; +} + +/** What is wrong with a repository's connection, in the order a person would say it. */ +export type ConnectionIssueKind = "unbound" | "retired" | "disconnected" | "rejected" | "owner_left" | "error"; + +export interface ConnectionIssue { + kind: ConnectionIssueKind; + /** The chip's words: short, lower-case, plain. */ + label: string; + /** What the chip's tooltip adds: why, in a sentence. */ + title: string; + /** The credential the repository runs through, when the list still has it. Absent when it is retired or the repository has none. */ + credential?: CredentialSummary; +} + +/** + * Why a repository's connection is not healthy, or null when it is. Joined by `credentialId`: a credential the list no longer holds + * — retired when its person left, or its connection removed — is a disconnected one, not an absent one, and its repository is + * exactly as stranded. Not only `status === "Error"`: a repository whose credential was refused or disconnected stays "Active" + * until something flips it, and every run through it fails meanwhile. + * + * `credentialsLoaded` guards the join: before the credential list arrives, "not in the list" says nothing. + */ +export function repositoryConnectionIssue(repository: Pick, credentialById: ReadonlyMap, credentialsLoaded: boolean): ConnectionIssue | null { + if (repository.status === "Paused") return null; + + if (!repository.credentialId) { + return repository.status === "Error" ? { kind: "unbound", label: "needs new credential", title: repository.lastError ?? "This repository has no credential to authenticate with." } : null; + } + + const credential = credentialById.get(repository.credentialId); + + if (!credential) { + return credentialsLoaded ? { kind: "retired", label: "credential disconnected", title: "The credential this repository runs through is gone — retired when its owner left the team, or its connection was removed. Link the repository to one that works." } : errorOnly(repository); + } + + switch (credentialHealthWord(credential)) { + case "disconnected": return { kind: "disconnected", label: "credential disconnected", title: `“${credential.displayName}” was disconnected, so nothing bound through it can authenticate.`, credential }; + case "rejected": return { kind: "rejected", label: isStaticToken(credential) ? "needs a new token" : "needs reconnect", title: credential.lastError ?? `Its provider refused “${credential.displayName}”.`, credential }; + case "owner_left": return { kind: "owner_left", label: "owner left", title: `“${credential.displayName}” belongs to someone who left the team; it will be retired. Link the repository to a credential that stays.`, credential }; + default: return repository.status === "Error" ? { kind: "error", label: "needs new credential", title: repository.lastError ?? "This repository needs a credential that works.", credential } : null; + } +} + +function errorOnly(repository: Pick): ConnectionIssue | null { + return repository.status === "Error" ? { kind: "error", label: "needs new credential", title: repository.lastError ?? "This repository needs a credential that works." } : null; +} diff --git a/frontend/src/lib/webhookState.ts b/frontend/src/lib/webhookState.ts index 831027377..de86230b0 100644 --- a/frontend/src/lib/webhookState.ts +++ b/frontend/src/lib/webhookState.ts @@ -260,6 +260,18 @@ const REJECTION_COPY: Record RejectionCopy> = { headline: "A pull request from someone without a role on the repository", remedy: `Not a fault. This trigger starts runs only for pull requests written by someone with a role on the repository at ${who} — and, for new commits, pushed by the author or by someone with a role — which is what a trigger does on a public or internal repository unless told otherwise. To let anyone's pull request start it, set “Pull requests from” to Anyone on the trigger. At most one of these is recorded per pull request per day, however many arrive.`, }), + // The two refusals that blame the repository's CREDENTIAL, not the delivery and not the author. Each is told apart from "author_not_member" + // on purpose: setting "Pull requests from" to Anyone fixes neither, and an operator sent there would change a trigger that was never wrong. + credential_rejected: (who) => ({ + tone: "bad", + headline: "The repository's credential was refused", + remedy: `${who} would not accept the credential this repository runs through, so CodeSpace could not check who wrote the pull request and started nothing. The author is not in question — the credential is. Reconnect it, or link the repository to one that works; the next pull request event starts the run.`, + }), + author_standing_unknown: (who) => ({ + tone: "bad", + headline: "Who wrote the pull request could not be read", + remedy: `${who} did not say who wrote the pull request — a 403 for a credential without the read_api scope or a role on the project, a rate limit, or no answer in time — so the trigger started nothing. Setting “Pull requests from” to Anyone is not the fix: nothing was learned about the author. If it was a 403, give the credential what it needs, or replace it, and the same pull request is admitted; a rate limit or no answer clears by itself, and the next pull request event asks again.`, + }), // A head commit that already ran arriving again — a close/reopen loop. Never newer code: a push moves // the head, and a new head always runs. pull_request_debounced: () => ({ diff --git a/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.test.tsx b/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.test.tsx index 871f6173d..493c055bd 100644 --- a/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.test.tsx +++ b/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.test.tsx @@ -7,8 +7,8 @@ import { renderRoute, stubFetch } from "@/test/route-harness"; vi.mock("@/components/AppShell", () => ({ AppShell: () => })); -// The app mounts DialogProvider in main.tsx, above the route tree the harness renders; nothing here opens a dialog. -vi.mock("@/components/dialog", async (importOriginal) => ({ ...(await importOriginal()), useConfirm: () => async () => false })); +// The app mounts DialogProvider in main.tsx, above the route tree the harness renders; nothing here opens a dialog (a fix on a broken row asks for useAlert, only to report a failure). +vi.mock("@/components/dialog", async (importOriginal) => ({ ...(await importOriginal()), useConfirm: () => async () => false, useAlert: () => async () => {} })); /** * "Remove from this project" is an unbind, "Move to another project" a move, and "Add repository" a bind — each takes @@ -32,8 +32,8 @@ describe("project repositories controls", () => { visibility: "Private", status: "Active", webUrl: "https://gitlab.test/acme/api", createdDate: "2026-01-01T00:00:00Z", }; - function renderAs(t: MeTeam) { - stubFetch({ "/api/users/me": me(t), "/api/projects/default": project, "/api/projects": [project], "/api/repositories": [repository], "/api/provider-instances": [instance], "/api/credentials": [] }); + function renderAs(t: MeTeam, over: { repositories?: RepositorySummary[]; credentials?: unknown[] } = {}) { + stubFetch({ "/api/users/me": me(t), "/api/projects/default": project, "/api/projects": [project], "/api/repositories": over.repositories ?? [repository], "/api/provider-instances": [instance], "/api/credentials": over.credentials ?? [] }); localStorage.setItem("codespace.activeTeamId", t.id); return renderRoute("/teams/platform-team/projects/default"); @@ -63,4 +63,31 @@ describe("project repositories controls", () => { expect(screen.getByTitle("Move to another project")).toBeTruthy(); expect(screen.getByRole("button", { name: /Add repository/ })).toBeTruthy(); }); + + // The project page showed only a credential column, so a repository running through a dead credential read as fine here. + const credential = (over: Record) => ({ id: "c-1", teamId: "t-1", providerInstanceId: "pi-1", ownerUserId: "u-1", ownerUserName: "Maya", ownership: "Personal", authType: "OAuth", displayName: "Maya's GitLab", status: "Active", createdDate: "2026-01-01T00:00:00Z", ...over }); + const onCredential = (over: Partial = {}): RepositorySummary => ({ ...repository, credentialId: "c-1", ...over }); + + it("flags a repository on this page whose credential was refused, although it is still marked Active — with the fix beside it", async () => { + await renderAs(team("Admin", ["repos.manage", "credentials.manage", "members.manage"]), { credentials: [credential({ status: "Error" })], repositories: [onCredential()] }); + + await waitFor(() => expect(screen.getByText("needs reconnect")).toBeTruthy()); + + expect(screen.getByRole("button", { name: "Reconnect" })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Re-link credential/ })).toBeTruthy(); + }); + + it("says nothing about a repository whose credential works", async () => { + await renderAs(team("Admin", ["repos.manage", "credentials.manage", "members.manage"]), { credentials: [credential({})], repositories: [onCredential()] }); + + await waitFor(() => expect(screen.getByTitle("Open in provider")).toBeTruthy()); + + expect(screen.queryByText(/needs reconnect|credential disconnected|needs new credential/)).toBeNull(); + }); + + it("flags a repository whose credential is gone from the list as disconnected", async () => { + await renderAs(team("Admin", ["repos.manage", "credentials.manage", "members.manage"]), { credentials: [], repositories: [onCredential({ credentialId: "retired-1" })] }); + + await waitFor(() => expect(screen.getByText("credential disconnected")).toBeTruthy()); + }); }); diff --git a/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.tsx b/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.tsx index 4d49e6ee9..f55a0030a 100644 --- a/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.tsx +++ b/frontend/src/routes/_app.teams.$teamSlug.projects.$projectSlug.tsx @@ -5,7 +5,9 @@ import { ApiError } from "@/api/request"; import { projectsApi } from "@/api/projects"; import type { RepositorySummary } from "@/api/types"; import { useProject, useProjects } from "@/hooks/use-projects"; -import { useCredentials, useProviderInstances } from "@/hooks/use-credentials"; +import { useProviderInstances } from "@/hooks/use-credentials"; +import { ConnectionIssueChip, RepositoryConnectionFix } from "@/components/remedy/RepositoryConnectionIssue"; +import { useRepositoryConnectionIssues } from "@/hooks/use-repository-connection-issues"; import { useRepositories, useUnbindRepository } from "@/hooks/use-repositories"; import { TeamPermissions, useTeamPermissions } from "@/hooks/use-team-management"; import { useConfirm } from "@/components/dialog"; @@ -76,11 +78,11 @@ function ProjectDetailPage() { // project filter the backend can't match, or an "all repos" fetch would fire during resolve. const reposQuery = useRepositories({ projectId, enabled: !!projectId }); const providerInstances = useProviderInstances(); - const credentials = useCredentials(); + const connection = useRepositoryConnectionIssues(); const allProjects = useProjects(); const instanceById = useMemo(() => new Map((providerInstances.data ?? []).map(i => [i.id, i])), [providerInstances.data]); - const credentialById = useMemo(() => new Map((credentials.data ?? []).map(c => [c.id, c])), [credentials.data]); + const credentialById = connection.credentialById; const [addRepoOpen, setAddRepoOpen] = useState(false); // Connect remote sits next to Add repository because Connections only matter @@ -241,10 +243,12 @@ function ProjectDetailPage() { {repos.map(r => { const instance = instanceById.get(r.providerInstanceId); const credential = r.credentialId ? credentialById.get(r.credentialId) : undefined; + const issue = connection.issueOf(r); return ( navigate({ to: "/teams/$teamSlug/repositories/$repoFullPath", params: { teamSlug, repoFullPath: encodeURIComponent(r.fullPath) }, @@ -254,8 +258,11 @@ function ProjectDetailPage() {
{instance && }
-
{r.name}
+
{r.name}{issue && }
{r.fullPath}
+ {/* The same chip and the same fix as the team's list: a project page showed only a credential column, so a + repository running through a dead credential read as fine here and failed every run. */} + {issue && }
diff --git a/frontend/src/styles/ai-code-space.css b/frontend/src/styles/ai-code-space.css index ee6c06bcf..49b83d6b1 100644 --- a/frontend/src/styles/ai-code-space.css +++ b/frontend/src/styles/ai-code-space.css @@ -1681,11 +1681,14 @@ html, body, * { scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, .16) trans .cn-cred { display: flex; flex-direction: column; gap: 7px; padding: 8px 10px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; } /* Top line: method · since · Disconnect (pinned right). Capability warnings, if any, drop to their own wrapping line below (.cn-cap-warns) so they never crowd or overflow this line. */ -.cn-cred-main { display: flex; align-items: center; gap: 10px; } +/* Wraps: a credential row now carries its health chip, how many repositories are bound through it, and up to three actions. The + first action (margin-left:auto) stays pinned right; the rest follow it. */ +.cn-cred-main { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; } .cn-cred-method { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: var(--ink-2); white-space: nowrap; } .cn-cred-method svg { color: var(--muted); } .cn-cred-since { font-size: 11.5px; color: var(--muted); white-space: nowrap; } -.cn-cred-x { margin-left: auto; flex-shrink: 0; height: 28px; padding: 0 10px; font-size: 12px; } +.cn-cred-x { flex-shrink: 0; height: 28px; padding: 0 10px; font-size: 12px; } +.cn-cred-main .cn-cred-x:first-of-type { margin-left: auto; } /* Capability warnings — a wrapping line listing what this token CAN'T do. A leading amber label ("Missing access") explains the chips; the row wraps freely so two or six warnings both fit without overflowing the credential card. */ @@ -1755,6 +1758,7 @@ html, body, * { scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, .16) trans /* Inline status banners (info / error) */ .cn-banner { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); margin-bottom: 12px; } .cn-banner-err { background: #FCEDED; border-color: #F2C5C5; } +.cn-banner-warn { background: #FBF1E2; border-color: #ECDAB5; } .cn-banner-h { font-size: 13px; font-weight: 600; color: var(--ink); margin-bottom: 2px; } .cn-banner-p { font-size: 12.5px; color: var(--muted); line-height: 1.5; } @@ -1798,6 +1802,11 @@ html, body, * { scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, .16) trans .acs-root .tbl tr[data-status="error"] td:first-child { box-shadow: inset 3px 0 0 0 #B07A2E; /* amber left accent */ } +/* A repository whose CREDENTIAL is broken reads the same as one already in Error — it fails every run just the same — even while + its status has not been flipped. data-issue is set by the connection join, not by status. */ +.acs-root .tbl tr[data-issue] td { background: rgba(176, 122, 46, 0.06); } +.acs-root .tbl tr[data-issue]:hover td { background: rgba(176, 122, 46, 0.10); } +.acs-root .tbl tr[data-issue] td:first-child { box-shadow: inset 3px 0 0 0 #B07A2E; } .acs-root .tbl tr[data-status="unreachable"] td { background: rgba(194, 69, 69, 0.05); } .acs-root .tbl tr[data-status="unreachable"]:hover td { background: rgba(194, 69, 69, 0.10); } .acs-root .tbl tr[data-status="unreachable"] td:first-child { @@ -6524,6 +6533,42 @@ body.chat-resizing .acs-root .app { transition: none; } .room-err-attn .room-err-av { border-color: var(--rm-warn-line); color: var(--rm-warn); } .room-err-attn .room-err-raw { border-color: var(--rm-warn-line); color: var(--rm-warn); } +/* provider remediation card — a failure on a git connection and the buttons that fix it. It IS the room's error card (same shell, + same attention palette) with its own rows: who/what the failure names, and who to ask when a button is not the viewer's. Once the + connection works again it turns to the room's good palette and leads with Re-run. */ +/* The room's semantic tokens are scoped to .room-room; this card also lives on the run detail page, the webhook tab and a repository's + header, where they are not defined. Declared here with the room's own values so it reads the same wherever it is mounted. */ +.rmd, .rmd-repo-issue { + --rm-good: #1F6B3F; --rm-good-bg: #E9F4EE; --rm-good-line: #C2E0CF; + --rm-warn: #A8590E; --rm-warn-bg: #FBF0DF; --rm-warn-line: #EFDABC; + --rm-danger: #9E3535; --rm-err-bg: #FBEDED; --rm-err-line: #F0C9C9; +} +.rmd-subjects { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.rmd-subject { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 2px 9px; border: 1px solid var(--line); border-radius: 999px; background: #fff; font-size: 11.5px; line-height: 1.6; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rmd-subject[data-works="true"] { border-color: var(--rm-good-line); color: var(--rm-good); } +.rmd-subject .cn-cred-owner { margin-left: 2px; } +.rmd-ask { margin: 10px 0 0; font-size: 12px; line-height: 1.55; color: var(--ink-2); } +.rmd-ask-list { margin: 3px 0 5px; padding-left: 18px; } +.rmd-ask-list strong { font-weight: 600; color: var(--ink); } +.repo-fix .rmd-ask { margin: 0; } +.rmd-fixed { border-color: var(--rm-good-line); background: var(--rm-good-bg); } +.rmd-fixed .room-err-av { border-color: var(--rm-good-line); color: var(--rm-good); } +.rmd-repos { margin: 0 0 12px; padding: 0 0 0 18px; font-size: 12.5px; color: var(--ink-2); line-height: 1.7; } +.rmd-candidates { display: flex; flex-direction: column; gap: 6px; } +.rmd-candidate { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 9px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); color: var(--ink); font: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: background .12s, border-color .12s; } +.rmd-candidate:hover:not(:disabled) { background: var(--panel-2); border-color: var(--line-2); } +.rmd-candidate:disabled { opacity: .6; cursor: default; } +.rmd-candidate-main { display: inline-flex; align-items: center; gap: 7px; min-width: 0; flex-wrap: wrap; } +.rmd-candidate-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rmd-candidate-go { flex: none; font-size: 12px; color: var(--accent); font-weight: 500; } +/* The repository page's connection row, above the tabs: chip, the sentence behind it, then the fixes. */ +.rmd-repo-issue { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 12px 0 0; padding: 9px 12px; border: 1px solid var(--rm-warn-line); border-radius: 10px; background: var(--rm-warn-bg); font-size: 12.5px; color: var(--ink-2); } +.rmd-repo-issue-text { flex: 1 1 260px; min-width: 0; line-height: 1.5; } +.rmd-repo-issue .repo-fix { margin-top: 0; display: flex; gap: 6px; flex-wrap: wrap; } +.repo-fix { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +/* A refused delivery's remedy, inside the webhook tab's list of refusals. */ +.rmd-delivery-fix { margin-top: 10px; } + /* buttons (turn footer + error remediation) */ .room-btn, .room-btn-primary, .room-btn-ghost { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 13px; border-radius: 8px; font: inherit; font-size: 12.5px; cursor: pointer; } .room-btn { border: 1px solid var(--line); background: var(--panel); color: var(--ink-2); }