| author | |
| committer | |
| log | c637dfc454342c156dbe61ee8513f5e0ac0c4691 |
| tree | e0b1f3a079ddccfd1edd2352b42f6728855fcae4 |
| parent | 6e9b43cb84c1b93c348d76880a0bf0083f395e47 |
| signature | Signed by PGP key B5690EEEBB952194 |
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); |
| 1590 | 1590 | ||
| 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 | ); | ||
| 1600 | 1597 | ||
| 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 | ); |
| 1604 | 1606 |
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 | } |
| 322 | 322 | ||
| 323 | function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { | ||
| 324 | return new GitManagerError({ | ||
| 325 | operation, | ||
| 326 | detail, | ||
| 327 | ...(cause !== undefined ? { cause } : {}), | ||
| 328 | }); | ||
| 329 | } | ||
| 330 | |||
| 331 | function limitContext(value: string, maxChars: number): string { | 323 | function 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* () { |
| 535 | 527 | ||
| 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 | ); | ||
| 543 | 542 | ||
| 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 | } |
| 1298 | 1302 | ||
| 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 | }); |
| 1334 | 1338 | ||
| 1335 | const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`); | 1339 | const bodyFile = path.join( |
| 1336 | yield* fileSystem | 1340 | 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 | } |
| 1549 | 1562 | ||
| 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 | } |
| 1577 | 1592 | ||
| 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 | } |
| 1615 | 1631 | ||
| 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"; |
| 1648 | 1664 | ||
| 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 | } |
| 1661 | 1679 | ||
| 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 | }); |
| 1673 | 1691 | ||
| 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 | } |
| 1683 | 1706 | ||
| 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* () { |
| 1687 | 1710 | ||
| 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 | ); |
| 1694 | 1723 |
apps/server/src/git/GitWorkflowService.test.ts+58-1| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import { assert, describe, it, vi } from "@effect/vitest"; | 1 | import { assert, describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | ||
| 5 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; | ||
| 6 | |||
| 5 | import * as GitManager from "./GitManager.ts"; | 7 | import * as GitManager from "./GitManager.ts"; |
| 6 | import * as GitWorkflowService from "./GitWorkflowService.ts"; | 8 | import * as GitWorkflowService from "./GitWorkflowService.ts"; |
| 7 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 9 | import * 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") {} |
| 96 | 96 | ||
| 97 | const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => | ||
| 98 | new GitManagerError({ | ||
| 99 | operation, | ||
| 100 | detail: `${detail} (${cwd})`, | ||
| 101 | }); | ||
| 102 | |||
| 103 | const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => | ||
| 104 | new GitCommandError({ | ||
| 105 | operation, | ||
| 106 | command: "vcs-route", | ||
| 107 | cwd, | ||
| 108 | detail, | ||
| 109 | }); | ||
| 110 | |||
| 111 | function nonRepositoryLocalStatus(): VcsStatusLocalResult { | 97 | function 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* registry | 142 | 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 | }); |
| 175 | 161 | ||
| ... | @@ -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* registry | 166 | 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 | }); |
| 199 | 187 | ||
| 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* registry | 190 | 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* registry | 218 | 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 @@ |
| 1 | import { assert, it, describe } from "@effect/vitest"; | 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Cause from "effect/Cause"; | ||
| 3 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Duration from "effect/Duration"; | 5 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Exit from "effect/Exit"; | 7 | import * as Exit from "effect/Exit"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Logger from "effect/Logger"; | ||
| 9 | import * as Option from "effect/Option"; | 11 | import * as Option from "effect/Option"; |
| 10 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 11 | import * as Scope from "effect/Scope"; | 13 | import * 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 | ); | ||
| 495 | 524 | ||
| 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]); |
| 506 | 535 | ||
| 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 | }); |
| 510 | 547 | ||
| 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 | }); |
| 575 | 612 | ||
| 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 @@ |
| 1 | import * as Cause from "effect/Cause"; | ||
| 1 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 2 | import * as Duration from "effect/Duration"; | 3 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * 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"; |
| 26 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | 27 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); |
| 27 | const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); | 28 | const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); |
| 28 | const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); | 29 | const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); |
| 30 | const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; | ||
| 31 | const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; | ||
| 32 | |||
| 33 | function boundedDiagnosticValue(value: string): string { | ||
| 34 | return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); | ||
| 35 | } | ||
| 36 | |||
| 37 | function 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 | |||
| 56 | function 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 | |||
| 72 | function 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 | |||
| 82 | export 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 | } | ||
| 29 | 115 | ||
| 30 | interface VcsStatusChange { | 116 | interface 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 | } |
| 320 | 406 | ||
| 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 |
| 352 | 352 | ||
| 353 | export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", { | 353 | export 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 | }) { |