authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 13:16:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 13:16:32-07:00
logc637dfc454342c156dbe61ee8513f5e0ac0c4691
treee0b1f3a079ddccfd1edd2352b42f6728855fcae4
parent6e9b43cb84c1b93c348d76880a0bf0083f395e47
signature Signed by PGP key B5690EEEBB952194

[codex] enrich Git workflow errors (#3241)

Co-authored-by: codex <codex@users.noreply.github.com>

7 files changed, 359 insertions(+), 131 deletions(-)

apps/server/src/git/GitManager.test.ts+8-6
...@@ -1589,16 +1589,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1589,16 +1589,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1589 yield* initRepo(repoDir);1589 yield* initRepo(repoDir);
15901590
1591 const { manager } = yield* makeManager();1591 const { manager } = yield* makeManager();
1592 const errorMessage = yield* runStackedAction(manager, {1592 const error = yield* runStackedAction(manager, {
1593 cwd: repoDir,1593 cwd: repoDir,
1594 action: "commit",1594 action: "commit",
1595 featureBranch: true,1595 featureBranch: true,
1596 }).pipe(1596 }).pipe(Effect.flip);
1597 Effect.flip,
1598 Effect.map((error) => error.message),
1599 );
16001597
1601 expect(errorMessage).toContain("no changes to commit");1598 expect(error).toMatchObject({
1599 _tag: "GitManagerError",
1600 operation: "runFeatureBranchStep",
1601 cwd: repoDir,
1602 });
1603 expect(error.message).toContain("no changes to commit");
1602 }),1604 }),
1603 );1605 );
16041606
apps/server/src/git/GitManager.ts+86-57
...@@ -320,14 +320,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {...@@ -320,14 +320,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
320 };320 };
321}321}
322322
323function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
324 return new GitManagerError({
325 operation,
326 detail,
327 ...(cause !== undefined ? { cause } : {}),
328 });
329}
330
331function limitContext(value: string, maxChars: number): string {323function limitContext(value: string, maxChars: number): string {
332 if (value.length <= maxChars) return value;324 if (value.length <= maxChars) return value;
333 return `${value.slice(0, maxChars)}\n\n[truncated]`;325 return `${value.slice(0, maxChars)}\n\n[truncated]`;
...@@ -535,17 +527,27 @@ export const make = Effect.gen(function* () {...@@ -535,17 +527,27 @@ export const make = Effect.gen(function* () {
535527
536 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });528 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
537 const serverSettingsService = yield* ServerSettings.ServerSettingsService;529 const serverSettingsService = yield* ServerSettings.ServerSettingsService;
538 const randomUUIDv4 = crypto.randomUUIDv4.pipe(530 const randomUUIDv4 = (cwd: string) =>
539 Effect.mapError((cause) =>531 crypto.randomUUIDv4.pipe(
540 gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause),532 Effect.mapError(
541 ),533 (cause) =>
542 );534 new GitManagerError({
535 operation: "randomUUIDv4",
536 cwd,
537 detail: "Failed to generate Git operation identifier.",
538 cause,
539 }),
540 ),
541 );
543542
544 const createProgressEmitter = (543 const createProgressEmitter = (
545 input: { cwd: string; action: GitStackedAction },544 input: { cwd: string; action: GitStackedAction },
546 options?: GitRunStackedActionOptions,545 options?: GitRunStackedActionOptions,
547 ) =>546 ) =>
548 (options?.actionId === undefined ? randomUUIDv4 : Effect.succeed(options.actionId)).pipe(547 (options?.actionId === undefined
548 ? randomUUIDv4(input.cwd)
549 : Effect.succeed(options.actionId)
550 ).pipe(
549 Effect.map((actionId) => {551 Effect.map((actionId) => {
550 const reporter = options?.progressReporter;552 const reporter = options?.progressReporter;
551 const emit = (event: GitActionProgressPayload) =>553 const emit = (event: GitActionProgressPayload) =>
...@@ -1284,16 +1286,18 @@ export const make = Effect.gen(function* () {...@@ -1284,16 +1286,18 @@ export const make = Effect.gen(function* () {
1284 const details = yield* gitCore.statusDetails(cwd);1286 const details = yield* gitCore.statusDetails(cwd);
1285 const branch = details.branch ?? fallbackBranch;1287 const branch = details.branch ?? fallbackBranch;
1286 if (!branch) {1288 if (!branch) {
1287 return yield* gitManagerError(1289 return yield* new GitManagerError({
1288 "runPrStep",1290 operation: "runPrStep",
1289 "Cannot create a pull request from detached HEAD.",1291 cwd,
1290 );1292 detail: "Cannot create a pull request from detached HEAD.",
1293 });
1291 }1294 }
1292 if (!details.hasUpstream) {1295 if (!details.hasUpstream) {
1293 return yield* gitManagerError(1296 return yield* new GitManagerError({
1294 "runPrStep",1297 operation: "runPrStep",
1295 "Current branch has not been pushed. Push before creating a PR.",1298 cwd,
1296 );1299 detail: "Current branch has not been pushed. Push before creating a PR.",
1300 });
1297 }1301 }
12981302
1299 const headContext = yield* resolveBranchHeadContext(cwd, {1303 const headContext = yield* resolveBranchHeadContext(cwd, {
...@@ -1332,14 +1336,21 @@ export const make = Effect.gen(function* () {...@@ -1332,14 +1336,21 @@ export const make = Effect.gen(function* () {
1332 modelSelection,1336 modelSelection,
1333 });1337 });
13341338
1335 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`);1339 const bodyFile = path.join(
1336 yield* fileSystem1340 tempDir,
1337 .writeFileString(bodyFile, generated.body)1341 `t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`,
1338 .pipe(1342 );
1339 Effect.mapError((cause) =>1343 yield* fileSystem.writeFileString(bodyFile, generated.body).pipe(
1340 gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause),1344 Effect.mapError(
1341 ),1345 (cause) =>
1342 );1346 new GitManagerError({
1347 operation: "runPrStep",
1348 cwd,
1349 detail: "Failed to write pull request body temp file.",
1350 cause,
1351 }),
1352 ),
1353 );
1343 yield* emit({1354 yield* emit({
1344 kind: "phase_started",1355 kind: "phase_started",
1345 phase: "pr",1356 phase: "pr",
...@@ -1541,10 +1552,12 @@ export const make = Effect.gen(function* () {...@@ -1541,10 +1552,12 @@ export const make = Effect.gen(function* () {
1541 };1552 };
1542 }1553 }
1543 if (existingBranchBeforeFetchPath === rootWorktreePath) {1554 if (existingBranchBeforeFetchPath === rootWorktreePath) {
1544 return yield* gitManagerError(1555 return yield* new GitManagerError({
1545 "preparePullRequestThread",1556 operation: "preparePullRequestThread",
1546 "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.",1557 cwd: input.cwd,
1547 );1558 detail:
1559 "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.",
1560 });
1548 }1561 }
15491562
1550 yield* materializePullRequestHeadBranch(1563 yield* materializePullRequestHeadBranch(
...@@ -1569,10 +1582,12 @@ export const make = Effect.gen(function* () {...@@ -1569,10 +1582,12 @@ export const make = Effect.gen(function* () {
1569 };1582 };
1570 }1583 }
1571 if (existingBranchAfterFetchPath === rootWorktreePath) {1584 if (existingBranchAfterFetchPath === rootWorktreePath) {
1572 return yield* gitManagerError(1585 return yield* new GitManagerError({
1573 "preparePullRequestThread",1586 operation: "preparePullRequestThread",
1574 "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.",1587 cwd: input.cwd,
1575 );1588 detail:
1589 "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.",
1590 });
1576 }1591 }
15771592
1578 const worktree = yield* gitCore.createWorktree({1593 const worktree = yield* gitCore.createWorktree({
...@@ -1607,10 +1622,11 @@ export const make = Effect.gen(function* () {...@@ -1607,10 +1622,11 @@ export const make = Effect.gen(function* () {
1607 modelSelection,1622 modelSelection,
1608 });1623 });
1609 if (!suggestion) {1624 if (!suggestion) {
1610 return yield* gitManagerError(1625 return yield* new GitManagerError({
1611 "runFeatureBranchStep",1626 operation: "runFeatureBranchStep",
1612 "Cannot create a feature branch because there are no changes to commit.",1627 cwd,
1613 );1628 detail: "Cannot create a feature branch because there are no changes to commit.",
1629 });
1614 }1630 }
16151631
1616 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);1632 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
...@@ -1647,16 +1663,18 @@ export const make = Effect.gen(function* () {...@@ -1647,16 +1663,18 @@ export const make = Effect.gen(function* () {
1647 const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr";1663 const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr";
16481664
1649 if (input.featureBranch && !wantsCommit) {1665 if (input.featureBranch && !wantsCommit) {
1650 return yield* gitManagerError(1666 return yield* new GitManagerError({
1651 "runStackedAction",1667 operation: "runStackedAction",
1652 "Feature-branch checkout is only supported for commit actions.",1668 cwd: input.cwd,
1653 );1669 detail: "Feature-branch checkout is only supported for commit actions.",
1670 });
1654 }1671 }
1655 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {1672 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
1656 return yield* gitManagerError(1673 return yield* new GitManagerError({
1657 "runStackedAction",1674 operation: "runStackedAction",
1658 "Commit local changes before creating a PR.",1675 cwd: input.cwd,
1659 );1676 detail: "Commit local changes before creating a PR.",
1677 });
1660 }1678 }
16611679
1662 const phases: GitActionProgressPhase[] = [1680 const phases: GitActionProgressPhase[] = [
...@@ -1672,13 +1690,18 @@ export const make = Effect.gen(function* () {...@@ -1672,13 +1690,18 @@ export const make = Effect.gen(function* () {
1672 });1690 });
16731691
1674 if (!input.featureBranch && wantsPush && !initialStatus.branch) {1692 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
1675 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");1693 return yield* new GitManagerError({
1694 operation: "runStackedAction",
1695 cwd: input.cwd,
1696 detail: "Cannot push from detached HEAD.",
1697 });
1676 }1698 }
1677 if (!input.featureBranch && wantsPr && !initialStatus.branch) {1699 if (!input.featureBranch && wantsPr && !initialStatus.branch) {
1678 return yield* gitManagerError(1700 return yield* new GitManagerError({
1679 "runStackedAction",1701 operation: "runStackedAction",
1680 "Cannot create a pull request from detached HEAD.",1702 cwd: input.cwd,
1681 );1703 detail: "Cannot create a pull request from detached HEAD.",
1704 });
1682 }1705 }
16831706
1684 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };1707 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };
...@@ -1687,8 +1710,14 @@ export const make = Effect.gen(function* () {...@@ -1687,8 +1710,14 @@ export const make = Effect.gen(function* () {
16871710
1688 const modelSelection = yield* serverSettingsService.getSettings.pipe(1711 const modelSelection = yield* serverSettingsService.getSettings.pipe(
1689 Effect.map((settings) => settings.textGenerationModelSelection),1712 Effect.map((settings) => settings.textGenerationModelSelection),
1690 Effect.mapError((cause) =>1713 Effect.mapError(
1691 gitManagerError("runStackedAction", "Failed to get server settings.", cause),1714 (cause) =>
1715 new GitManagerError({
1716 operation: "runStackedAction",
1717 cwd: input.cwd,
1718 detail: "Failed to get server settings.",
1719 cause,
1720 }),
1692 ),1721 ),
1693 );1722 );
16941723
apps/server/src/git/GitWorkflowService.test.ts+58-1
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import { assert, describe, it, vi } from "@effect/vitest";1import { assert, describe, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
44
5import { VcsRepositoryDetectionError } from "@t3tools/contracts";
6
5import * as GitManager from "./GitManager.ts";7import * as GitManager from "./GitManager.ts";
6import * as GitWorkflowService from "./GitWorkflowService.ts";8import * as GitWorkflowService from "./GitWorkflowService.ts";
7import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";9import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
...@@ -132,4 +134,59 @@ describe("GitWorkflowService", () => {...@@ -132,4 +134,59 @@ describe("GitWorkflowService", () => {
132 ),134 ),
133 ),135 ),
134 );136 );
137
138 it.effect("structures workflow detection failures without exposing upstream details", () => {
139 const cause = new VcsRepositoryDetectionError({
140 operation: "VcsDriverRegistry.detect",
141 cwd: "/repo",
142 detail: "upstream detail must stay in the cause chain",
143 });
144
145 return Effect.gen(function* () {
146 const workflow = yield* GitWorkflowService.GitWorkflowService;
147 const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip);
148
149 expect(error).toMatchObject({
150 _tag: "GitManagerError",
151 operation: "GitWorkflowService.status",
152 cwd: "/repo",
153 detail: "Failed to detect a VCS repository for this Git workflow.",
154 });
155 expect(error.message).not.toContain(cause.detail);
156 }).pipe(
157 Effect.provide(
158 makeLayer({
159 detect: () => Effect.fail(cause),
160 }),
161 ),
162 );
163 });
164
165 it.effect("structures command detection failures without exposing upstream details", () => {
166 const cause = new VcsRepositoryDetectionError({
167 operation: "VcsDriverRegistry.detect",
168 cwd: "/repo",
169 detail: "upstream command detail must stay in the cause chain",
170 });
171
172 return Effect.gen(function* () {
173 const workflow = yield* GitWorkflowService.GitWorkflowService;
174 const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip);
175
176 expect(error).toMatchObject({
177 _tag: "GitCommandError",
178 operation: "GitWorkflowService.listRefs",
179 command: "vcs-route",
180 cwd: "/repo",
181 detail: "Failed to detect a VCS repository for this Git command.",
182 });
183 expect(error.message).not.toContain(cause.detail);
184 }).pipe(
185 Effect.provide(
186 makeLayer({
187 detect: () => Effect.fail(cause),
188 }),
189 ),
190 );
191 });
135});192});
apps/server/src/git/GitWorkflowService.ts+52-62
...@@ -94,20 +94,6 @@ export class GitWorkflowService extends Context.Service<...@@ -94,20 +94,6 @@ export class GitWorkflowService extends Context.Service<
94 }94 }
95>()("t3/git/GitWorkflowService") {}95>()("t3/git/GitWorkflowService") {}
9696
97const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) =>
98 new GitManagerError({
99 operation,
100 detail: `${detail} (${cwd})`,
101 });
102
103const unsupportedGitCommand = (operation: string, cwd: string, detail: string) =>
104 new GitCommandError({
105 operation,
106 command: "vcs-route",
107 cwd,
108 detail,
109 });
110
111function nonRepositoryLocalStatus(): VcsStatusLocalResult {97function nonRepositoryLocalStatus(): VcsStatusLocalResult {
112 return {98 return {
113 isRepo: false,99 isRepo: false,
...@@ -153,23 +139,23 @@ export const make = Effect.gen(function* () {...@@ -153,23 +139,23 @@ export const make = Effect.gen(function* () {
153 operation: string,139 operation: string,
154 cwd: string,140 cwd: string,
155 ) {141 ) {
156 const handle = yield* registry142 const handle = yield* registry.resolve({ cwd }).pipe(
157 .resolve({ cwd })143 Effect.mapError(
158 .pipe(144 (cause) =>
159 Effect.mapError((error) =>145 new GitManagerError({
160 unsupportedGitWorkflow(
161 operation,146 operation,
162 cwd,147 cwd,
163 error instanceof Error ? error.message : String(error),148 detail: "Failed to resolve the VCS driver for this Git workflow.",
164 ),149 cause,
165 ),150 }),
166 );151 ),
152 );
167 if (handle.kind !== "git") {153 if (handle.kind !== "git") {
168 return yield* unsupportedGitWorkflow(154 return yield* new GitManagerError({
169 operation,155 operation,
170 cwd,156 cwd,
171 `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`,157 detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`,
172 );158 });
173 }159 }
174 });160 });
175161
...@@ -177,48 +163,50 @@ export const make = Effect.gen(function* () {...@@ -177,48 +163,50 @@ export const make = Effect.gen(function* () {
177 operation: string,163 operation: string,
178 cwd: string,164 cwd: string,
179 ) {165 ) {
180 const handle = yield* registry166 const handle = yield* registry.resolve({ cwd }).pipe(
181 .resolve({ cwd })167 Effect.mapError(
182 .pipe(168 (cause) =>
183 Effect.mapError((error) =>169 new GitCommandError({
184 unsupportedGitCommand(
185 operation,170 operation,
171 command: "vcs-route",
186 cwd,172 cwd,
187 error instanceof Error ? error.message : String(error),173 detail: "Failed to resolve the VCS driver for this Git command.",
188 ),174 cause,
189 ),175 }),
190 );176 ),
177 );
191 if (handle.kind !== "git") {178 if (handle.kind !== "git") {
192 return yield* unsupportedGitCommand(179 return yield* new GitCommandError({
193 operation,180 operation,
181 command: "vcs-route",
194 cwd,182 cwd,
195 `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,183 detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,
196 );184 });
197 }185 }
198 });186 });
199187
200 const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")(188 const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")(
201 function* (operation: string, cwd: string) {189 function* (operation: string, cwd: string) {
202 const handle = yield* registry190 const handle = yield* registry.detect({ cwd }).pipe(
203 .detect({ cwd })191 Effect.mapError(
204 .pipe(192 (cause) =>
205 Effect.mapError((error) =>193 new GitManagerError({
206 unsupportedGitWorkflow(
207 operation,194 operation,
208 cwd,195 cwd,
209 error instanceof Error ? error.message : String(error),196 detail: "Failed to detect a VCS repository for this Git workflow.",
210 ),197 cause,
211 ),198 }),
212 );199 ),
200 );
213 if (!handle) {201 if (!handle) {
214 return false;202 return false;
215 }203 }
216 if (handle.kind !== "git") {204 if (handle.kind !== "git") {
217 return yield* unsupportedGitWorkflow(205 return yield* new GitManagerError({
218 operation,206 operation,
219 cwd,207 cwd,
220 `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`,208 detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`,
221 );209 });
222 }210 }
223 return true;211 return true;
224 },212 },
...@@ -227,26 +215,28 @@ export const make = Effect.gen(function* () {...@@ -227,26 +215,28 @@ export const make = Effect.gen(function* () {
227 const detectGitRepositoryForCommand = Effect.fn(215 const detectGitRepositoryForCommand = Effect.fn(
228 "GitWorkflowService.detectGitRepositoryForCommand",216 "GitWorkflowService.detectGitRepositoryForCommand",
229 )(function* (operation: string, cwd: string) {217 )(function* (operation: string, cwd: string) {
230 const handle = yield* registry218 const handle = yield* registry.detect({ cwd }).pipe(
231 .detect({ cwd })219 Effect.mapError(
232 .pipe(220 (cause) =>
233 Effect.mapError((error) =>221 new GitCommandError({
234 unsupportedGitCommand(
235 operation,222 operation,
223 command: "vcs-route",
236 cwd,224 cwd,
237 error instanceof Error ? error.message : String(error),225 detail: "Failed to detect a VCS repository for this Git command.",
238 ),226 cause,
239 ),227 }),
240 );228 ),
229 );
241 if (!handle) {230 if (!handle) {
242 return false;231 return false;
243 }232 }
244 if (handle.kind !== "git") {233 if (handle.kind !== "git") {
245 return yield* unsupportedGitCommand(234 return yield* new GitCommandError({
246 operation,235 operation,
236 command: "vcs-route",
247 cwd,237 cwd,
248 `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,238 detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,
249 );239 });
250 }240 }
251 return true;241 return true;
252 });242 });
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+61-3
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import { assert, it, describe } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Cause from "effect/Cause";
3import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";7import * as Exit from "effect/Exit";
7import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
9import * as Option from "effect/Option";11import * as Option from "effect/Option";
10import * as Path from "effect/Path";12import * as Path from "effect/Path";
11import * as Scope from "effect/Scope";13import * as Scope from "effect/Scope";
...@@ -190,6 +192,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -190,6 +192,7 @@ describe("VcsStatusBroadcaster", () => {
190 ? Effect.fail(192 ? Effect.fail(
191 new GitManagerError({193 new GitManagerError({
192 operation: "VcsStatusBroadcaster.test",194 operation: "VcsStatusBroadcaster.test",
195 cwd: "/repo",
193 detail: "remote status failed",196 detail: "remote status failed",
194 }),197 }),
195 )198 )
...@@ -431,6 +434,12 @@ describe("VcsStatusBroadcaster", () => {...@@ -431,6 +434,12 @@ describe("VcsStatusBroadcaster", () => {
431 remoteInvalidationCalls: 0,434 remoteInvalidationCalls: 0,
432 remoteStatusRefreshUpstreamValues: [] as Array<boolean | undefined>,435 remoteStatusRefreshUpstreamValues: [] as Array<boolean | undefined>,
433 };436 };
437 const privateCwd = "/private/user/workspace/repo";
438 const nestedCause = new Error("private nested VCS failure");
439 const messages: Array<ReadonlyArray<unknown>> = [];
440 const logger = Logger.make<unknown, void>(({ message }) => {
441 messages.push(message as ReadonlyArray<unknown>);
442 });
434 let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null;443 let firstRemoteAttemptDeferred: Deferred.Deferred<void> | null = null;
435 const testLayer = VcsStatusBroadcaster.layer.pipe(444 const testLayer = VcsStatusBroadcaster.layer.pipe(
436 Layer.provideMerge(NodeServices.layer),445 Layer.provideMerge(NodeServices.layer),
...@@ -449,7 +458,9 @@ describe("VcsStatusBroadcaster", () => {...@@ -449,7 +458,9 @@ describe("VcsStatusBroadcaster", () => {
449 return Effect.fail(458 return Effect.fail(
450 new GitManagerError({459 new GitManagerError({
451 operation: "VcsStatusBroadcaster.test",460 operation: "VcsStatusBroadcaster.test",
452 detail: "initial remote status failed",461 cwd: privateCwd,
462 detail: "private initial remote status failure",
463 cause: nestedCause,
453 }),464 }),
454 ).pipe(465 ).pipe(
455 Effect.ensuring(466 Effect.ensuring(
...@@ -480,7 +491,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -480,7 +491,7 @@ describe("VcsStatusBroadcaster", () => {
480 const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>();491 const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
481 yield* Stream.runForEach(492 yield* Stream.runForEach(
482 broadcaster.streamStatus(493 broadcaster.streamStatus(
483 { cwd: "/repo" },494 { cwd: privateCwd },
484 { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) },495 { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) },
485 ),496 ),
486 (event) =>497 (event) =>
...@@ -492,6 +503,24 @@ describe("VcsStatusBroadcaster", () => {...@@ -492,6 +503,24 @@ describe("VcsStatusBroadcaster", () => {
492 yield* Deferred.await(firstRemoteAttemptDeferred);503 yield* Deferred.await(firstRemoteAttemptDeferred);
493 yield* Effect.yieldNow;504 yield* Effect.yieldNow;
494 assert.equal(state.remoteStatusCalls, 1);505 assert.equal(state.remoteStatusCalls, 1);
506 assert.deepStrictEqual(
507 messages.find((message) => message[0] === "VCS remote status refresh failed"),
508 [
509 "VCS remote status refresh failed",
510 {
511 cwdLength: privateCwd.length,
512 reasonCount: 1,
513 failureCount: 1,
514 failureTags: ["GitManagerError"],
515 failureOperations: ["VcsStatusBroadcaster.test"],
516 defectCount: 0,
517 defectTags: [],
518 interruptionCount: 0,
519 consecutiveFailures: 1,
520 nextDelayMs: 30_000,
521 },
522 ],
523 );
495524
496 yield* TestClock.adjust(Duration.seconds(30));525 yield* TestClock.adjust(Duration.seconds(30));
497 const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);526 const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
...@@ -505,7 +534,15 @@ describe("VcsStatusBroadcaster", () => {...@@ -505,7 +534,15 @@ describe("VcsStatusBroadcaster", () => {
505 assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]);534 assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, false]);
506535
507 yield* Scope.close(scope, Exit.void);536 yield* Scope.close(scope, Exit.void);
508 }).pipe(Effect.provide(Layer.merge(testLayer, TestClock.layer())));537 }).pipe(
538 Effect.provide(
539 Layer.mergeAll(
540 testLayer,
541 TestClock.layer(),
542 Logger.layer([logger], { mergeWithExisting: false }),
543 ),
544 ),
545 );
509 });546 });
510547
511 it.effect("delays automatic refresh when a cached remote snapshot is available", () => {548 it.effect("delays automatic refresh when a cached remote snapshot is available", () => {
...@@ -573,6 +610,27 @@ describe("VcsStatusBroadcaster", () => {...@@ -573,6 +610,27 @@ describe("VcsStatusBroadcaster", () => {
573 );610 );
574 });611 });
575612
613 it("summarizes refresh causes without exposing nested failure details", () => {
614 const nestedCause = new Error("private nested failure detail");
615 const failure = new GitManagerError({
616 operation: "VcsStatusBroadcaster.remoteStatus",
617 cwd: "/private/user/workspace/repo",
618 detail: "private Git failure detail",
619 cause: nestedCause,
620 });
621 const cause = Cause.combine(Cause.fail(failure), Cause.die(new TypeError("private defect")));
622
623 assert.deepStrictEqual(VcsStatusBroadcaster.remoteRefreshFailureDiagnostics(cause), {
624 reasonCount: 2,
625 failureCount: 1,
626 failureTags: ["GitManagerError"],
627 failureOperations: ["VcsStatusBroadcaster.remoteStatus"],
628 defectCount: 1,
629 defectTags: ["TypeError"],
630 interruptionCount: 0,
631 });
632 });
633
576 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {634 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
577 const state = {635 const state = {
578 currentLocalStatus: baseLocalStatus,636 currentLocalStatus: baseLocalStatus,
apps/server/src/vcs/VcsStatusBroadcaster.ts+93-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as Cause from "effect/Cause";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -26,6 +27,91 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts";...@@ -26,6 +27,91 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts";
26const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);27const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
27const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30);28const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30);
28const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15);29const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15);
30const MAX_FAILURE_DIAGNOSTIC_VALUES = 8;
31const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128;
32
33function boundedDiagnosticValue(value: string): string {
34 return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH);
35}
36
37function diagnosticValueTag(value: unknown): string {
38 try {
39 if (
40 typeof value === "object" &&
41 value !== null &&
42 "_tag" in value &&
43 typeof value._tag === "string"
44 ) {
45 return boundedDiagnosticValue(value._tag);
46 }
47 if (value instanceof Error) {
48 return boundedDiagnosticValue(value.name);
49 }
50 return typeof value;
51 } catch {
52 return "Uninspectable";
53 }
54}
55
56function diagnosticFailureOperation(value: unknown): string | undefined {
57 try {
58 if (
59 typeof value === "object" &&
60 value !== null &&
61 "operation" in value &&
62 typeof value.operation === "string"
63 ) {
64 return boundedDiagnosticValue(value.operation);
65 }
66 } catch {
67 return undefined;
68 }
69 return undefined;
70}
71
72function addUniqueDiagnosticValue(values: Array<string>, value: string | undefined): void {
73 if (
74 value !== undefined &&
75 values.length < MAX_FAILURE_DIAGNOSTIC_VALUES &&
76 !values.includes(value)
77 ) {
78 values.push(value);
79 }
80}
81
82export function remoteRefreshFailureDiagnostics(cause: Cause.Cause<unknown>) {
83 const failureTags: Array<string> = [];
84 const failureOperations: Array<string> = [];
85 const defectTags: Array<string> = [];
86 let failureCount = 0;
87 let defectCount = 0;
88 let interruptionCount = 0;
89
90 for (const reason of cause.reasons) {
91 if (Cause.isFailReason(reason)) {
92 failureCount += 1;
93 addUniqueDiagnosticValue(failureTags, diagnosticValueTag(reason.error));
94 addUniqueDiagnosticValue(failureOperations, diagnosticFailureOperation(reason.error));
95 continue;
96 }
97 if (Cause.isDieReason(reason)) {
98 defectCount += 1;
99 addUniqueDiagnosticValue(defectTags, diagnosticValueTag(reason.defect));
100 continue;
101 }
102 interruptionCount += 1;
103 }
104
105 return {
106 reasonCount: cause.reasons.length,
107 failureCount,
108 failureTags,
109 failureOperations,
110 defectCount,
111 defectTags,
112 interruptionCount,
113 };
114}
29115
30interface VcsStatusChange {116interface VcsStatusChange {
31 readonly cwd: string;117 readonly cwd: string;
...@@ -318,14 +404,19 @@ export const make = Effect.gen(function* () {...@@ -318,14 +404,19 @@ export const make = Effect.gen(function* () {
318 return activeInterval;404 return activeInterval;
319 }405 }
320406
407 const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason);
408 if (interruptionReasons.length > 0) {
409 return yield* Effect.failCause(Cause.fromReasons<never>(interruptionReasons));
410 }
411
321 const consecutiveFailures = yield* Ref.updateAndGet(412 const consecutiveFailures = yield* Ref.updateAndGet(
322 consecutiveFailuresRef,413 consecutiveFailuresRef,
323 (count) => count + 1,414 (count) => count + 1,
324 );415 );
325 const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval);416 const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval);
326 yield* Effect.logWarning("VCS remote status refresh failed", {417 yield* Effect.logWarning("VCS remote status refresh failed", {
327 cwd,418 cwdLength: cwd.length,
328 detail: exit.cause.toString(),419 ...remoteRefreshFailureDiagnostics(exit.cause),
329 consecutiveFailures,420 consecutiveFailures,
330 nextDelayMs: Duration.toMillis(nextDelay),421 nextDelayMs: Duration.toMillis(nextDelay),
331 });422 });
packages/contracts/src/git.ts+1
...@@ -352,6 +352,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE...@@ -352,6 +352,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE
352352
353export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", {353export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", {
354 operation: Schema.String,354 operation: Schema.String,
355 cwd: Schema.String,
355 detail: Schema.String,356 detail: Schema.String,
356 cause: Schema.optional(Schema.Defect()),357 cause: Schema.optional(Schema.Defect()),
357}) {358}) {