| author | |
| committer | |
| log | 5055de3ae483d5ece45695ad682b9766ff0cf423 |
| tree | 51fdfc811c0dd7aa3760e1373fcc2a09e611881a |
| parent | 876f8656e310f365eab98678e68bcc45615fec7f |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 213 insertions(+), 29 deletions(-)
apps/mobile/src/connection/background-activity-scopes.ts+2-1| ... | @@ -45,7 +45,8 @@ function scopeForSubscription( | ... | @@ -45,7 +45,8 @@ function scopeForSubscription( |
| 45 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { | 45 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { |
| 46 | return null; | 46 | return null; |
| 47 | } | 47 | } |
| 48 | const input = observation.input as { readonly cwd?: unknown }; | 48 | const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown }; |
| 49 | if (input.includeRemote === false) return null; | ||
| 49 | return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; | 50 | return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; |
| 50 | } | 51 | } |
| 51 | 52 |
apps/mobile/src/connection/background-activity.test.ts+11| ... | @@ -12,6 +12,12 @@ describe("mobile background activity", () => { | ... | @@ -12,6 +12,12 @@ describe("mobile background activity", () => { |
| 12 | it.effect("retains VCS demand only while the mobile subscription is active", () => | 12 | it.effect("retains VCS demand only while the mobile subscription is active", () => |
| 13 | Effect.gen(function* () { | 13 | Effect.gen(function* () { |
| 14 | const environmentId = EnvironmentId.make("mobile-environment"); | 14 | const environmentId = EnvironmentId.make("mobile-environment"); |
| 15 | const releasePassive = yield* observeMobileBackgroundActivitySubscription({ | ||
| 16 | environmentId, | ||
| 17 | method: WS_METHODS.subscribeVcsStatus, | ||
| 18 | input: { cwd: "/workspace", includeRemote: false }, | ||
| 19 | }); | ||
| 20 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); | ||
| 15 | const release = yield* observeMobileBackgroundActivitySubscription({ | 21 | const release = yield* observeMobileBackgroundActivitySubscription({ |
| 16 | environmentId, | 22 | environmentId, |
| 17 | method: WS_METHODS.subscribeVcsStatus, | 23 | method: WS_METHODS.subscribeVcsStatus, |
| ... | @@ -22,6 +28,11 @@ describe("mobile background activity", () => { | ... | @@ -22,6 +28,11 @@ describe("mobile background activity", () => { |
| 22 | { type: "vcs-status", cwd: "/workspace" }, | 28 | { type: "vcs-status", cwd: "/workspace" }, |
| 23 | ]); | 29 | ]); |
| 24 | 30 | ||
| 31 | yield* releasePassive; | ||
| 32 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([ | ||
| 33 | { type: "vcs-status", cwd: "/workspace" }, | ||
| 34 | ]); | ||
| 35 | |||
| 25 | yield* release; | 36 | yield* release; |
| 26 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); | 37 | expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]); |
| 27 | }), | 38 | }), |
apps/server/src/git/GitManager.test.ts+124| ... | @@ -6,7 +6,10 @@ import * as NodeChildProcess from "node:child_process"; | ... | @@ -6,7 +6,10 @@ import * as NodeChildProcess from "node:child_process"; |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { it } from "@effect/vitest"; | 7 | import { it } from "@effect/vitest"; |
| 8 | import * as Duration from "effect/Duration"; | 8 | import * as Duration from "effect/Duration"; |
| 9 | import * as Context from "effect/Context"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Deferred from "effect/Deferred"; | ||
| 12 | import * as Exit from "effect/Exit"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | 13 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Layer from "effect/Layer"; | 14 | import * as Layer from "effect/Layer"; |
| 12 | import * as Logger from "effect/Logger"; | 15 | import * as Logger from "effect/Logger"; |
| ... | @@ -16,11 +19,13 @@ import * as References from "effect/References"; | ... | @@ -16,11 +19,13 @@ import * as References from "effect/References"; |
| 16 | import * as Result from "effect/Result"; | 19 | import * as Result from "effect/Result"; |
| 17 | import * as Schema from "effect/Schema"; | 20 | import * as Schema from "effect/Schema"; |
| 18 | import * as Scope from "effect/Scope"; | 21 | import * as Scope from "effect/Scope"; |
| 22 | import * as Stream from "effect/Stream"; | ||
| 19 | import { TestClock } from "effect/testing"; | 23 | import { TestClock } from "effect/testing"; |
| 20 | import { ChildProcessSpawner } from "effect/process"; | 24 | import { ChildProcessSpawner } from "effect/process"; |
| 21 | import { expect } from "vite-plus/test"; | 25 | import { expect } from "vite-plus/test"; |
| 22 | import type { | 26 | import type { |
| 23 | GitActionProgressEvent, | 27 | GitActionProgressEvent, |
| 28 | GitManagerServiceError, | ||
| 24 | GitPreparePullRequestThreadInput, | 29 | GitPreparePullRequestThreadInput, |
| 25 | ModelSelection, | 30 | ModelSelection, |
| 26 | } from "@t3tools/contracts"; | 31 | } from "@t3tools/contracts"; |
| ... | @@ -43,6 +48,11 @@ import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | ... | @@ -43,6 +48,11 @@ import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 43 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 48 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 44 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 49 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 45 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 50 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 51 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | ||
| 52 | import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; | ||
| 53 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; | ||
| 54 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | ||
| 55 | import * as GitWorkflowService from "./GitWorkflowService.ts"; | ||
| 46 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; | 56 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; |
| 47 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; | 57 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; |
| 48 | import { | 58 | import { |
| ... | @@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( | ... | @@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( |
| 778 | ); | 788 | ); |
| 779 | 789 | ||
| 780 | it.layer(layerGitManagerTest)("GitManager", (it) => { | 790 | it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 791 | it.effect("passive worktree status streams do not start remote refreshes", () => | ||
| 792 | Effect.gen(function* () { | ||
| 793 | const repoDir = yield* makeTempDir("t3code-passive-vcs-"); | ||
| 794 | yield* initRepo(repoDir); | ||
| 795 | const remoteDir = yield* createBareRemote(); | ||
| 796 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 797 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 798 | const worktreeDir = NodePath.join(repoDir, "worktree"); | ||
| 799 | yield* runGit(repoDir, ["worktree", "add", "-b", "feature/passive", worktreeDir]); | ||
| 800 | yield* runGit(worktreeDir, ["push", "-u", "origin", "feature/passive"]); | ||
| 801 | |||
| 802 | const { manager } = yield* makeManager(); | ||
| 803 | let remoteReads = 0; | ||
| 804 | const workflowContext = yield* Layer.build( | ||
| 805 | GitWorkflowService.layer.pipe( | ||
| 806 | Layer.provide( | ||
| 807 | Layer.succeed(GitManager.GitManager, { | ||
| 808 | ...manager, | ||
| 809 | remoteStatus: (input, options) => | ||
| 810 | manager.remoteStatus(input, options).pipe( | ||
| 811 | Effect.tap(() => | ||
| 812 | Effect.sync(() => { | ||
| 813 | remoteReads += 1; | ||
| 814 | }), | ||
| 815 | ), | ||
| 816 | ), | ||
| 817 | }), | ||
| 818 | ), | ||
| 819 | Layer.provide( | ||
| 820 | VcsDriverRegistry.layer.pipe( | ||
| 821 | Layer.provide(VcsProjectConfig.layer), | ||
| 822 | Layer.provide(VcsProcess.layer), | ||
| 823 | ), | ||
| 824 | ), | ||
| 825 | ), | ||
| 826 | ); | ||
| 827 | const broadcasterContext = yield* Layer.build( | ||
| 828 | VcsStatusBroadcaster.layer.pipe( | ||
| 829 | Layer.provide( | ||
| 830 | Layer.succeed( | ||
| 831 | GitWorkflowService.GitWorkflowService, | ||
| 832 | Context.get(workflowContext, GitWorkflowService.GitWorkflowService), | ||
| 833 | ), | ||
| 834 | ), | ||
| 835 | Layer.provide( | ||
| 836 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 837 | hasDemand: () => Effect.succeed(true), | ||
| 838 | shouldRunScopeWork: () => Effect.succeed(true), | ||
| 839 | }), | ||
| 840 | ), | ||
| 841 | ), | ||
| 842 | ); | ||
| 843 | const broadcaster = Context.get( | ||
| 844 | broadcasterContext, | ||
| 845 | VcsStatusBroadcaster.VcsStatusBroadcaster, | ||
| 846 | ); | ||
| 847 | const passiveScope = yield* Scope.make(); | ||
| 848 | const snapshots = yield* Deferred.make<void, GitManagerServiceError>(); | ||
| 849 | const localUpdated = yield* Deferred.make<void>(); | ||
| 850 | const remoteUpdated = yield* Deferred.make<void>(); | ||
| 851 | let snapshotCount = 0; | ||
| 852 | for (const cwd of [repoDir, worktreeDir]) { | ||
| 853 | yield* Stream.runForEach( | ||
| 854 | broadcaster.streamStatus( | ||
| 855 | { cwd, includeRemote: false }, | ||
| 856 | { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) }, | ||
| 857 | ), | ||
| 858 | (event) => { | ||
| 859 | if ( | ||
| 860 | cwd === worktreeDir && | ||
| 861 | event._tag === "localUpdated" && | ||
| 862 | event.local.hasWorkingTreeChanges | ||
| 863 | ) { | ||
| 864 | return Deferred.succeed(localUpdated, undefined); | ||
| 865 | } | ||
| 866 | if (cwd === worktreeDir && event._tag === "remoteUpdated") { | ||
| 867 | expect(event.remote?.hasUpstream).toBe(true); | ||
| 868 | return Deferred.succeed(remoteUpdated, undefined); | ||
| 869 | } | ||
| 870 | if (event._tag !== "snapshot") return Effect.void; | ||
| 871 | expect(event.local.isRepo).toBe(true); | ||
| 872 | expect(event.local.refName).toBe(cwd === repoDir ? "main" : "feature/passive"); | ||
| 873 | snapshotCount += 1; | ||
| 874 | return snapshotCount === 2 ? Deferred.succeed(snapshots, undefined) : Effect.void; | ||
| 875 | }, | ||
| 876 | ).pipe( | ||
| 877 | Effect.catchCause((cause) => Deferred.failCause(snapshots, cause)), | ||
| 878 | Effect.forkIn(passiveScope), | ||
| 879 | ); | ||
| 880 | } | ||
| 881 | yield* Deferred.await(snapshots); | ||
| 882 | expect(remoteReads).toBe(0); | ||
| 883 | yield* TestClock.adjust("1 minute"); | ||
| 884 | expect(remoteReads).toBe(0); | ||
| 885 | |||
| 886 | const fs = yield* FileSystem.FileSystem; | ||
| 887 | yield* fs.writeFileString(NodePath.join(worktreeDir, "README.md"), "changed\n"); | ||
| 888 | yield* broadcaster.refreshLocalStatus(worktreeDir); | ||
| 889 | yield* Deferred.await(localUpdated); | ||
| 890 | expect(remoteReads).toBe(0); | ||
| 891 | |||
| 892 | const activeScope = yield* Scope.make(); | ||
| 893 | yield* Stream.runDrain(broadcaster.streamStatus({ cwd: worktreeDir })).pipe( | ||
| 894 | Effect.forkIn(activeScope), | ||
| 895 | ); | ||
| 896 | yield* Deferred.await(remoteUpdated); | ||
| 897 | expect(remoteReads).toBe(1); | ||
| 898 | yield* Scope.close(activeScope, Exit.void); | ||
| 899 | yield* TestClock.adjust("1 minute"); | ||
| 900 | expect(remoteReads).toBe(1); | ||
| 901 | yield* Scope.close(passiveScope, Exit.void); | ||
| 902 | }), | ||
| 903 | ); | ||
| 904 | |||
| 781 | it.effect("status includes draft PR metadata when branch already has a draft PR", () => | 905 | it.effect("status includes draft PR metadata when branch already has a draft PR", () => |
| 782 | Effect.gen(function* () { | 906 | Effect.gen(function* () { |
| 783 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | 907 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+38-13| ... | @@ -594,7 +594,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -594,7 +594,7 @@ describe("VcsStatusBroadcaster", () => { |
| 594 | }, | 594 | }, |
| 595 | ); | 595 | ); |
| 596 | 596 | ||
| 597 | it.effect("streams a local snapshot first and remote updates later", () => { | 597 | it.effect("passive streams retain cached remote status", () => { |
| 598 | const state = { | 598 | const state = { |
| 599 | currentLocalStatus: baseLocalStatus, | 599 | currentLocalStatus: baseLocalStatus, |
| 600 | currentRemoteStatus: baseRemoteStatus, | 600 | currentRemoteStatus: baseRemoteStatus, |
| ... | @@ -608,18 +608,25 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -608,18 +608,25 @@ describe("VcsStatusBroadcaster", () => { |
| 608 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; | 608 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 609 | const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | 609 | const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 610 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | 610 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 611 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { | 611 | yield* Stream.runForEach( |
| 612 | if (event._tag === "snapshot") { | 612 | broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), |
| 613 | return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); | 613 | (event) => { |
| 614 | } | 614 | if (event._tag === "snapshot") { |
| 615 | if (event._tag === "remoteUpdated") { | 615 | return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); |
| 616 | return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); | 616 | } |
| 617 | } | 617 | if (event._tag === "remoteUpdated") { |
| 618 | return Effect.void; | 618 | return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); |
| 619 | }).pipe(Effect.forkScoped); | 619 | } |
| 620 | return Effect.void; | ||
| 621 | }, | ||
| 622 | ).pipe(Effect.forkScoped); | ||
| 620 | 623 | ||
| 621 | const snapshot = yield* Deferred.await(snapshotDeferred); | 624 | const snapshot = yield* Deferred.await(snapshotDeferred); |
| 622 | yield* broadcaster.refreshStatus("/repo"); | 625 | yield* TestClock.adjust("1 minute"); |
| 626 | assert.equal(state.remoteStatusCalls, 0); | ||
| 627 | yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 628 | state.currentRemoteStatus = remoteStatusWithPr; | ||
| 629 | yield* broadcaster.refreshPullRequestStatus("/repo"); | ||
| 623 | const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); | 630 | const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); |
| 624 | 631 | ||
| 625 | assert.deepStrictEqual(snapshot, { | 632 | assert.deepStrictEqual(snapshot, { |
| ... | @@ -629,8 +636,16 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -629,8 +636,16 @@ describe("VcsStatusBroadcaster", () => { |
| 629 | } satisfies VcsStatusStreamEvent); | 636 | } satisfies VcsStatusStreamEvent); |
| 630 | assert.deepStrictEqual(remoteUpdated, { | 637 | assert.deepStrictEqual(remoteUpdated, { |
| 631 | _tag: "remoteUpdated", | 638 | _tag: "remoteUpdated", |
| 632 | remote: baseRemoteStatus, | 639 | remote: remoteStatusWithPr, |
| 633 | } satisfies VcsStatusStreamEvent); | 640 | } satisfies VcsStatusStreamEvent); |
| 641 | const cachedSnapshot = yield* Stream.runHead( | ||
| 642 | broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), | ||
| 643 | ); | ||
| 644 | assert.deepStrictEqual(Option.getOrThrow(cachedSnapshot), { | ||
| 645 | _tag: "snapshot", | ||
| 646 | local: baseLocalStatus, | ||
| 647 | remote: remoteStatusWithPr, | ||
| 648 | }); | ||
| 634 | }).pipe(Effect.provide(layerTestFor(state))); | 649 | }).pipe(Effect.provide(layerTestFor(state))); |
| 635 | }); | 650 | }); |
| 636 | 651 | ||
| ... | @@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => { |
| 1032 | }).pipe(Effect.provide(layerTest)); | 1047 | }).pipe(Effect.provide(layerTest)); |
| 1033 | }); | 1048 | }); |
| 1034 | 1049 | ||
| 1035 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { | 1050 | it.effect("releases remote demand while passive observers remain", () => { |
| 1036 | const state = { | 1051 | const state = { |
| 1037 | currentLocalStatus: baseLocalStatus, | 1052 | currentLocalStatus: baseLocalStatus, |
| 1038 | currentRemoteStatus: baseRemoteStatus, | 1053 | currentRemoteStatus: baseRemoteStatus, |
| ... | @@ -1092,6 +1107,16 @@ describe("VcsStatusBroadcaster", () => { | ... | @@ -1092,6 +1107,16 @@ describe("VcsStatusBroadcaster", () => { |
| 1092 | const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); | 1107 | const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 1093 | const firstScope = yield* Scope.make(); | 1108 | const firstScope = yield* Scope.make(); |
| 1094 | const secondScope = yield* Scope.make(); | 1109 | const secondScope = yield* Scope.make(); |
| 1110 | const passiveSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 1111 | yield* Stream.runForEach( | ||
| 1112 | broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), | ||
| 1113 | (event) => | ||
| 1114 | event._tag === "snapshot" | ||
| 1115 | ? Deferred.succeed(passiveSnapshot, event).pipe(Effect.ignore) | ||
| 1116 | : Effect.void, | ||
| 1117 | ).pipe(Effect.forkScoped); | ||
| 1118 | yield* Deferred.await(passiveSnapshot); | ||
| 1119 | assert.equal(state.remoteStatusCalls, 0); | ||
| 1095 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => | 1120 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => |
| 1096 | event._tag === "snapshot" | 1121 | event._tag === "snapshot" |
| 1097 | ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) | 1122 | ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) |
apps/server/src/vcs/VcsStatusBroadcaster.ts+15-10| ... | @@ -19,6 +19,7 @@ import type { | ... | @@ -19,6 +19,7 @@ import type { |
| 19 | VcsStatusRemoteResult, | 19 | VcsStatusRemoteResult, |
| 20 | VcsStatusResult, | 20 | VcsStatusResult, |
| 21 | VcsStatusStreamEvent, | 21 | VcsStatusStreamEvent, |
| 22 | VcsStatusSubscriptionInput, | ||
| 22 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 23 | import { mergeGitStatusParts } from "@t3tools/shared/git"; | 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 24 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; | 25 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; |
| ... | @@ -201,7 +202,7 @@ export class VcsStatusBroadcaster extends Context.Service< | ... | @@ -201,7 +202,7 @@ export class VcsStatusBroadcaster extends Context.Service< |
| 201 | cwd: string, | 202 | cwd: string, |
| 202 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; | 203 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; |
| 203 | readonly streamStatus: ( | 204 | readonly streamStatus: ( |
| 204 | input: VcsStatusInput, | 205 | input: VcsStatusSubscriptionInput, |
| 205 | options?: StreamStatusOptions, | 206 | options?: StreamStatusOptions, |
| 206 | ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>; | 207 | ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>; |
| 207 | } | 208 | } |
| ... | @@ -708,15 +709,19 @@ export const make = Effect.gen(function* () { | ... | @@ -708,15 +709,19 @@ export const make = Effect.gen(function* () { |
| 708 | const initialLocal = yield* getOrLoadLocalStatus(cwd); | 709 | const initialLocal = yield* getOrLoadLocalStatus(cwd); |
| 709 | const cachedStatus = yield* getCachedStatus(cwd); | 710 | const cachedStatus = yield* getCachedStatus(cwd); |
| 710 | const initialRemote = cachedStatus?.remote?.value ?? null; | 711 | const initialRemote = cachedStatus?.remote?.value ?? null; |
| 711 | yield* retainRemotePoller( | 712 | if (input.includeRemote !== false) { |
| 712 | cwd, | 713 | yield* retainRemotePoller( |
| 713 | input.cwd, | 714 | cwd, |
| 714 | options?.automaticRemoteRefreshInterval ?? | 715 | input.cwd, |
| 715 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), | 716 | options?.automaticRemoteRefreshInterval ?? |
| 716 | cachedStatus?.remote === null || cachedStatus?.remote === undefined, | 717 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), |
| 717 | ); | 718 | cachedStatus?.remote === null || cachedStatus?.remote === undefined, |
| 718 | 719 | ); | |
| 719 | const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); | 720 | } |
| 721 | const release = | ||
| 722 | input.includeRemote === false | ||
| 723 | ? Effect.void | ||
| 724 | : releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); | ||
| 720 | 725 | ||
| 721 | return Stream.concat( | 726 | return Stream.concat( |
| 722 | Stream.make({ | 727 | Stream.make({ |
apps/web/src/components/Sidebar.tsx+2-2| ... | @@ -1209,7 +1209,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1209,7 +1209,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1209 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | 1209 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| 1210 | ? vcsEnvironment.status({ | 1210 | ? vcsEnvironment.status({ |
| 1211 | environmentId: thread.environmentId, | 1211 | environmentId: thread.environmentId, |
| 1212 | input: { cwd: gitCwd }, | 1212 | input: { cwd: gitCwd, includeRemote: false }, |
| 1213 | }) | 1213 | }) |
| 1214 | : null, | 1214 | : null, |
| 1215 | ); | 1215 | ); |
| ... | @@ -2204,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -2204,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2204 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null | 2204 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| 2205 | ? vcsEnvironment.status({ | 2205 | ? vcsEnvironment.status({ |
| 2206 | environmentId: thread.environmentId, | 2206 | environmentId: thread.environmentId, |
| 2207 | input: { cwd: gitCwd }, | 2207 | input: { cwd: gitCwd, includeRemote: false }, |
| 2208 | }) | 2208 | }) |
| 2209 | : null, | 2209 | : null, |
| 2210 | ); | 2210 | ); |
apps/web/src/components/ThreadStatusIndicators.tsx+1-1| ... | @@ -910,7 +910,7 @@ export function ThreadRowLeadingStatus({ | ... | @@ -910,7 +910,7 @@ export function ThreadRowLeadingStatus({ |
| 910 | gitCwd !== null | 910 | gitCwd !== null |
| 911 | ? vcsEnvironment.status({ | 911 | ? vcsEnvironment.status({ |
| 912 | environmentId: thread.environmentId, | 912 | environmentId: thread.environmentId, |
| 913 | input: { cwd: gitCwd }, | 913 | input: { cwd: gitCwd, includeRemote: false }, |
| 914 | }) | 914 | }) |
| 915 | : null, | 915 | : null, |
| 916 | ); | 916 | ); |
apps/web/src/lib/backgroundActivityReporter.test.ts+9| ... | @@ -22,6 +22,12 @@ describe("wasRecentlyInteracted", () => { | ... | @@ -22,6 +22,12 @@ describe("wasRecentlyInteracted", () => { |
| 22 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 23 | const environmentId = EnvironmentId.make("environment-observation-test"); | 23 | const environmentId = EnvironmentId.make("environment-observation-test"); |
| 24 | const scope = { type: "vcs-status" as const, cwd: "/repo" }; | 24 | const scope = { type: "vcs-status" as const, cwd: "/repo" }; |
| 25 | const releasePassive = yield* observeBackgroundActivitySubscription({ | ||
| 26 | environmentId, | ||
| 27 | method: WS_METHODS.subscribeVcsStatus, | ||
| 28 | input: { cwd: scope.cwd, includeRemote: false }, | ||
| 29 | }); | ||
| 30 | expect(retainedBackgroundScopes(environmentId)).toEqual([]); | ||
| 25 | const release = yield* observeBackgroundActivitySubscription({ | 31 | const release = yield* observeBackgroundActivitySubscription({ |
| 26 | environmentId, | 32 | environmentId, |
| 27 | method: WS_METHODS.subscribeVcsStatus, | 33 | method: WS_METHODS.subscribeVcsStatus, |
| ... | @@ -30,6 +36,9 @@ describe("wasRecentlyInteracted", () => { | ... | @@ -30,6 +36,9 @@ describe("wasRecentlyInteracted", () => { |
| 30 | 36 | ||
| 31 | expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); | 37 | expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); |
| 32 | 38 | ||
| 39 | yield* releasePassive; | ||
| 40 | expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); | ||
| 41 | |||
| 33 | yield* release; | 42 | yield* release; |
| 34 | expect(retainedBackgroundScopes(environmentId)).toEqual([]); | 43 | expect(retainedBackgroundScopes(environmentId)).toEqual([]); |
| 35 | }), | 44 | }), |
apps/web/src/lib/backgroundActivityReporter.ts+2-1| ... | @@ -118,7 +118,8 @@ function scopeForSubscription( | ... | @@ -118,7 +118,8 @@ function scopeForSubscription( |
| 118 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { | 118 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { |
| 119 | return null; | 119 | return null; |
| 120 | } | 120 | } |
| 121 | const input = observation.input as { readonly cwd?: unknown }; | 121 | const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown }; |
| 122 | if (input.includeRemote === false) return null; | ||
| 122 | return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; | 123 | return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null; |
| 123 | } | 124 | } |
| 124 | 125 |
packages/contracts/src/git.ts+7| ... | @@ -111,6 +111,13 @@ export const VcsStatusInput = Schema.Struct({ | ... | @@ -111,6 +111,13 @@ export const VcsStatusInput = Schema.Struct({ |
| 111 | }); | 111 | }); |
| 112 | export type VcsStatusInput = typeof VcsStatusInput.Type; | 112 | export type VcsStatusInput = typeof VcsStatusInput.Type; |
| 113 | 113 | ||
| 114 | export const VcsStatusSubscriptionInput = Schema.Struct({ | ||
| 115 | ...VcsStatusInput.fields, | ||
| 116 | /** Passive observers receive cached remote status without retaining its refresh loop. */ | ||
| 117 | includeRemote: Schema.optional(Schema.Boolean), | ||
| 118 | }); | ||
| 119 | export type VcsStatusSubscriptionInput = typeof VcsStatusSubscriptionInput.Type; | ||
| 120 | |||
| 114 | export const VcsPullInput = Schema.Struct({ | 121 | export const VcsPullInput = Schema.Struct({ |
| 115 | cwd: TrimmedNonEmptyStringSchema, | 122 | cwd: TrimmedNonEmptyStringSchema, |
| 116 | }); | 123 | }); |
packages/contracts/src/rpc.ts+2-1| ... | @@ -121,6 +121,7 @@ import { | ... | @@ -121,6 +121,7 @@ import { |
| 121 | GitResolvePullRequestResult, | 121 | GitResolvePullRequestResult, |
| 122 | GitRunStackedActionInput, | 122 | GitRunStackedActionInput, |
| 123 | VcsStatusInput, | 123 | VcsStatusInput, |
| 124 | VcsStatusSubscriptionInput, | ||
| 124 | VcsStatusResult, | 125 | VcsStatusResult, |
| 125 | VcsStatusStreamEvent, | 126 | VcsStatusStreamEvent, |
| 126 | } from "./git.ts"; | 127 | } from "./git.ts"; |
| ... | @@ -1236,7 +1237,7 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, | ... | @@ -1236,7 +1237,7 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, |
| 1236 | }); | 1237 | }); |
| 1237 | 1238 | ||
| 1238 | const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { | 1239 | const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { |
| 1239 | payload: VcsStatusInput, | 1240 | payload: VcsStatusSubscriptionInput, |
| 1240 | success: VcsStatusStreamEvent, | 1241 | success: VcsStatusStreamEvent, |
| 1241 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), | 1242 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 1242 | stream: true, | 1243 | stream: true, |