diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 81b82d7de30..8bcd12629cb 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1577,16 +1577,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* initRepo(repoDir); const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { + const error = yield* runStackedAction(manager, { cwd: repoDir, action: "commit", featureBranch: true, - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); + }).pipe(Effect.flip); - expect(errorMessage).toContain("no changes to commit"); + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "runFeatureBranchStep", + cwd: repoDir, + }); + expect(error.message).toContain("no changes to commit"); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 88eb0e21282..46da2e6c1f9 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -320,14 +320,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } -function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { - return new GitManagerError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; @@ -535,17 +527,27 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => - gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause), - ), - ); + const randomUUIDv4 = (cwd: string) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "randomUUIDv4", + cwd, + detail: "Failed to generate Git operation identifier.", + cause, + }), + ), + ); const createProgressEmitter = ( input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => - (options?.actionId === undefined ? randomUUIDv4 : Effect.succeed(options.actionId)).pipe( + (options?.actionId === undefined + ? randomUUIDv4(input.cwd) + : Effect.succeed(options.actionId) + ).pipe( Effect.map((actionId) => { const reporter = options?.progressReporter; const emit = (event: GitActionProgressPayload) => @@ -1284,16 +1286,18 @@ export const make = Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { - return yield* gitManagerError( - "runPrStep", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } if (!details.hasUpstream) { - return yield* gitManagerError( - "runPrStep", - "Current branch has not been pushed. Push before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Current branch has not been pushed. Push before creating a PR.", + }); } const headContext = yield* resolveBranchHeadContext(cwd, { @@ -1332,14 +1336,21 @@ export const make = Effect.gen(function* () { modelSelection, }); - const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`); - yield* fileSystem - .writeFileString(bodyFile, generated.body) - .pipe( - Effect.mapError((cause) => - gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), - ), - ); + const bodyFile = path.join( + tempDir, + `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`, + ); + yield* fileSystem.writeFileString(bodyFile, generated.body).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runPrStep", + cwd, + detail: "Failed to write pull request body temp file.", + cause, + }), + ), + ); yield* emit({ kind: "phase_started", phase: "pr", @@ -1541,10 +1552,12 @@ export const make = Effect.gen(function* () { }; } if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } yield* materializePullRequestHeadBranch( @@ -1569,10 +1582,12 @@ export const make = Effect.gen(function* () { }; } if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: input.cwd, + detail: + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + }); } const worktree = yield* gitCore.createWorktree({ @@ -1607,10 +1622,11 @@ export const make = Effect.gen(function* () { modelSelection, }); if (!suggestion) { - return yield* gitManagerError( - "runFeatureBranchStep", - "Cannot create a feature branch because there are no changes to commit.", - ); + return yield* new GitManagerError({ + operation: "runFeatureBranchStep", + cwd, + detail: "Cannot create a feature branch because there are no changes to commit.", + }); } const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); @@ -1647,16 +1663,18 @@ export const make = Effect.gen(function* () { const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; if (input.featureBranch && !wantsCommit) { - return yield* gitManagerError( - "runStackedAction", - "Feature-branch checkout is only supported for commit actions.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Feature-branch checkout is only supported for commit actions.", + }); } if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit local changes before creating a PR.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Commit local changes before creating a PR.", + }); } const phases: GitActionProgressPhase[] = [ @@ -1672,13 +1690,18 @@ export const make = Effect.gen(function* () { }); if (!input.featureBranch && wantsPush && !initialStatus.branch) { - return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot push from detached HEAD.", + }); } if (!input.featureBranch && wantsPr && !initialStatus.branch) { - return yield* gitManagerError( - "runStackedAction", - "Cannot create a pull request from detached HEAD.", - ); + return yield* new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Cannot create a pull request from detached HEAD.", + }); } let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; @@ -1687,8 +1710,14 @@ export const make = Effect.gen(function* () { const modelSelection = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.textGenerationModelSelection), - Effect.mapError((cause) => - gitManagerError("runStackedAction", "Failed to get server settings.", cause), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "runStackedAction", + cwd: input.cwd, + detail: "Failed to get server settings.", + cause, + }), ), ); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 03cd624600d..2ea14b951fe 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,7 +1,9 @@ -import { assert, describe, it, vi } from "@effect/vitest"; +import { assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { VcsRepositoryDetectionError } from "@t3tools/contracts"; + import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; @@ -132,4 +134,59 @@ describe("GitWorkflowService", () => { ), ), ); + + it.effect("structures workflow detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + operation: "GitWorkflowService.status", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git workflow.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); + + it.effect("structures command detection failures without exposing upstream details", () => { + const cause = new VcsRepositoryDetectionError({ + operation: "VcsDriverRegistry.detect", + cwd: "/repo", + detail: "upstream command detail must stay in the cause chain", + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.listRefs", + command: "vcs-route", + cwd: "/repo", + detail: "Failed to detect a VCS repository for this Git command.", + }); + expect(error.message).not.toContain(cause.detail); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.fail(cause), + }), + ), + ); + }); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index f958b663006..100b9beadba 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -94,20 +94,6 @@ export class GitWorkflowService extends Context.Service< } >()("t3/git/GitWorkflowService") {} -const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => - new GitManagerError({ - operation, - detail: `${detail} (${cwd})`, - }); - -const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => - new GitCommandError({ - operation, - command: "vcs-route", - cwd, - detail, - }); - function nonRepositoryLocalStatus(): VcsStatusLocalResult { return { isRepo: false, @@ -153,23 +139,23 @@ export const make = Effect.gen(function* () { operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git workflow.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } }); @@ -177,48 +163,50 @@ export const make = Effect.gen(function* () { operation: string, cwd: string, ) { - const handle = yield* registry - .resolve({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.resolve({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to resolve the VCS driver for this Git command.", + cause, + }), + ), + ); if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } }); const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitWorkflow( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitManagerError({ operation, cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitWorkflow( + return yield* new GitManagerError({ operation, cwd, - `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, + }); } return true; }, @@ -227,26 +215,28 @@ export const make = Effect.gen(function* () { const detectGitRepositoryForCommand = Effect.fn( "GitWorkflowService.detectGitRepositoryForCommand", )(function* (operation: string, cwd: string) { - const handle = yield* registry - .detect({ cwd }) - .pipe( - Effect.mapError((error) => - unsupportedGitCommand( + const handle = yield* registry.detect({ cwd }).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ operation, + command: "vcs-route", cwd, - error instanceof Error ? error.message : String(error), - ), - ), - ); + detail: "Failed to detect a VCS repository for this Git command.", + cause, + }), + ), + ); if (!handle) { return false; } if (handle.kind !== "git") { - return yield* unsupportedGitCommand( + return yield* new GitCommandError({ operation, + command: "vcs-route", cwd, - `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, - ); + detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + }); } return true; }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index c14115e7119..032e48e4612 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,11 +1,13 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; @@ -190,6 +192,7 @@ describe("VcsStatusBroadcaster", () => { ? Effect.fail( new GitManagerError({ operation: "VcsStatusBroadcaster.test", + cwd: "/repo", detail: "remote status failed", }), ) @@ -431,6 +434,12 @@ describe("VcsStatusBroadcaster", () => { remoteInvalidationCalls: 0, remoteStatusRefreshUpstreamValues: [] as Array, }; + const privateCwd = "/private/user/workspace/repo"; + const nestedCause = new Error("private nested VCS failure"); + const messages: Array> = []; + const logger = Logger.make(({ message }) => { + messages.push(message as ReadonlyArray); + }); let firstRemoteAttemptDeferred: Deferred.Deferred | null = null; const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -449,7 +458,9 @@ describe("VcsStatusBroadcaster", () => { return Effect.fail( new GitManagerError({ operation: "VcsStatusBroadcaster.test", - detail: "initial remote status failed", + cwd: privateCwd, + detail: "private initial remote status failure", + cause: nestedCause, }), ).pipe( Effect.ensuring( @@ -480,7 +491,7 @@ describe("VcsStatusBroadcaster", () => { const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach( broadcaster.streamStatus( - { cwd: "/repo" }, + { cwd: privateCwd }, { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, ), (event) => @@ -492,6 +503,24 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.await(firstRemoteAttemptDeferred); yield* Effect.yieldNow; assert.equal(state.remoteStatusCalls, 1); + assert.deepStrictEqual( + messages.find((message) => message[0] === "VCS remote status refresh failed"), + [ + "VCS remote status refresh failed", + { + cwdLength: privateCwd.length, + reasonCount: 1, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.test"], + defectCount: 0, + defectTags: [], + interruptionCount: 0, + consecutiveFailures: 1, + nextDelayMs: 30_000, + }, + ], + ); yield* TestClock.adjust(Duration.seconds(30)); const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); @@ -505,7 +534,15 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]); yield* Scope.close(scope, Exit.void); - }).pipe(Effect.provide(Layer.merge(testLayer, TestClock.layer()))); + }).pipe( + Effect.provide( + Layer.mergeAll( + testLayer, + TestClock.layer(), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); }); it.effect("delays automatic refresh when a cached remote snapshot is available", () => { @@ -573,6 +610,27 @@ describe("VcsStatusBroadcaster", () => { ); }); + it("summarizes refresh causes without exposing nested failure details", () => { + const nestedCause = new Error("private nested failure detail"); + const failure = new GitManagerError({ + operation: "VcsStatusBroadcaster.remoteStatus", + cwd: "/private/user/workspace/repo", + detail: "private Git failure detail", + cause: nestedCause, + }); + const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect"))); + + assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), { + reasonCount: 2, + failureCount: 1, + failureTags: ["GitManagerError"], + failureOperations: ["VcsStatusBroadcaster.remoteStatus"], + defectCount: 1, + defectTags: ["TypeError"], + interruptionCount: 0, + }); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 860fc8075b3..c238154f58c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -26,6 +27,91 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; +const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; + +function boundedDiagnosticValue(value: string): string { + return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); +} + +function diagnosticValueTag(value: unknown): string { + try { + if ( + typeof value === "object" && + value !== null && + "_tag" in value && + typeof value._tag === "string" + ) { + return boundedDiagnosticValue(value._tag); + } + if (value instanceof Error) { + return boundedDiagnosticValue(value.name); + } + return typeof value; + } catch { + return "Uninspectable"; + } +} + +function diagnosticFailureOperation(value: unknown): string | undefined { + try { + if ( + typeof value === "object" && + value !== null && + "operation" in value && + typeof value.operation === "string" + ) { + return boundedDiagnosticValue(value.operation); + } + } catch { + return undefined; + } + return undefined; +} + +function addUniqueDiagnosticValue(values: Array, value: string | undefined): void { + if ( + value !== undefined && + values.length < MAX_FAILURE_DIAGNOSTIC_VALUES && + !values.includes(value) + ) { + values.push(value); + } +} + +export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { + const failureTags: Array = []; + const failureOperations: Array = []; + const defectTags: Array = []; + let failureCount = 0; + let defectCount = 0; + let interruptionCount = 0; + + for (const reason of cause.reasons) { + if (Cause.isFailReason(reason)) { + failureCount += 1; + addUniqueDiagnosticValue(failureTags, diagnosticValueTag(reason.error)); + addUniqueDiagnosticValue(failureOperations, diagnosticFailureOperation(reason.error)); + continue; + } + if (Cause.isDieReason(reason)) { + defectCount += 1; + addUniqueDiagnosticValue(defectTags, diagnosticValueTag(reason.defect)); + continue; + } + interruptionCount += 1; + } + + return { + reasonCount: cause.reasons.length, + failureCount, + failureTags, + failureOperations, + defectCount, + defectTags, + interruptionCount, + }; +} interface VcsStatusChange { readonly cwd: string; @@ -318,14 +404,19 @@ export const make = Effect.gen(function* () { return activeInterval; } + const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); + if (interruptionReasons.length > 0) { + return yield* Effect.failCause(Cause.fromReasons(interruptionReasons)); + } + const consecutiveFailures = yield* Ref.updateAndGet( consecutiveFailuresRef, (count) => count + 1, ); const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); yield* Effect.logWarning("VCS remote status refresh failed", { - cwd, - detail: exit.cause.toString(), + cwdLength: cwd.length, + ...remoteRefreshFailureDiagnostics(exit.cause), consecutiveFailures, nextDelayMs: Duration.toMillis(nextDelay), }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 7ee2a571963..15f71b1c559 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -347,6 +347,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass()("GitManagerError", { operation: Schema.String, + cwd: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect()), }) {