authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 20:53:27-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:53:27-03:00
log5055de3ae483d5ece45695ad682b9766ff0cf423
tree51fdfc811c0dd7aa3760e1373fcc2a09e611881a
parent876f8656e310f365eab98678e68bcc45615fec7f
signature Signed by PGP key B5690EEEBB952194

fix(vcs): passive sidebar rows stop retaining remote pollers (#15666)


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}
5152
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 ]);
2430
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";
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";7import { it } from "@effect/vitest";
8import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
9import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as Deferred from "effect/Deferred";
12import * as Exit from "effect/Exit";
10import * as FileSystem from "effect/FileSystem";13import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";14import * as Layer from "effect/Layer";
12import * as Logger from "effect/Logger";15import * 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";
16import * as Result from "effect/Result";19import * as Result from "effect/Result";
17import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
18import * as Scope from "effect/Scope";21import * as Scope from "effect/Scope";
22import * as Stream from "effect/Stream";
19import { TestClock } from "effect/testing";23import { TestClock } from "effect/testing";
20import { ChildProcessSpawner } from "effect/process";24import { ChildProcessSpawner } from "effect/process";
21import { expect } from "vite-plus/test";25import { expect } from "vite-plus/test";
22import type {26import 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";
43import * as TextGeneration from "../textGeneration/TextGeneration.ts";48import * as TextGeneration from "../textGeneration/TextGeneration.ts";
44import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";49import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
45import * as VcsProcess from "../vcs/VcsProcess.ts";50import * as VcsProcess from "../vcs/VcsProcess.ts";
51import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
52import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
53import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
54import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
55import * as GitWorkflowService from "./GitWorkflowService.ts";
46import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";56import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
47import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";57import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
48import {58import {
...@@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe(...@@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe(
778);788);
779789
780it.layer(layerGitManagerTest)("GitManager", (it) => {790it.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 );
596596
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);
620623
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);
624631
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 });
636651
...@@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => {...@@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => {
1032 }).pipe(Effect.provide(layerTest));1047 }).pipe(Effect.provide(layerTest));
1033 });1048 });
10341049
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";
23import { mergeGitStatusParts } from "@t3tools/shared/git";24import { mergeGitStatusParts } from "@t3tools/shared/git";
24import * as KeyedLock from "@t3tools/shared/KeyedLock";25import * 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,
718719 );
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);
720725
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 !== null1209 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 !== null2204 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 !== null910 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", () => {
3036
31 expect(retainedBackgroundScopes(environmentId)).toEqual([scope]);37 expect(retainedBackgroundScopes(environmentId)).toEqual([scope]);
3238
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}
124125
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});
112export type VcsStatusInput = typeof VcsStatusInput.Type;112export type VcsStatusInput = typeof VcsStatusInput.Type;
113113
114export 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});
119export type VcsStatusSubscriptionInput = typeof VcsStatusSubscriptionInput.Type;
120
114export const VcsPullInput = Schema.Struct({121export 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});
12371238
1238const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {1239const 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,