| 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 | 45 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { |
| 46 | 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 | 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 | 12 | it.effect("retains VCS demand only while the mobile subscription is active", () => |
| 13 | 13 | Effect.gen(function* () { |
| 14 | 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 | 21 | const release = yield* observeMobileBackgroundActivitySubscription({ |
| 16 | 22 | environmentId, |
| 17 | 23 | method: WS_METHODS.subscribeVcsStatus, |
| ... | ... | @@ -22,6 +28,11 @@ describe("mobile background activity", () => { |
| 22 | 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 | 36 | yield* release; |
| 26 | 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 | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | 7 | import { it } from "@effect/vitest"; |
| 8 | 8 | import * as Duration from "effect/Duration"; |
| 9 | import * as Context from "effect/Context"; | |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Deferred from "effect/Deferred"; | |
| 12 | import * as Exit from "effect/Exit"; | |
| 10 | 13 | import * as FileSystem from "effect/FileSystem"; |
| 11 | 14 | import * as Layer from "effect/Layer"; |
| 12 | 15 | import * as Logger from "effect/Logger"; |
| ... | ... | @@ -16,11 +19,13 @@ import * as References from "effect/References"; |
| 16 | 19 | import * as Result from "effect/Result"; |
| 17 | 20 | import * as Schema from "effect/Schema"; |
| 18 | 21 | import * as Scope from "effect/Scope"; |
| 22 | import * as Stream from "effect/Stream"; | |
| 19 | 23 | import { TestClock } from "effect/testing"; |
| 20 | 24 | import { ChildProcessSpawner } from "effect/process"; |
| 21 | 25 | import { expect } from "vite-plus/test"; |
| 22 | 26 | import type { |
| 23 | 27 | GitActionProgressEvent, |
| 28 | GitManagerServiceError, | |
| 24 | 29 | GitPreparePullRequestThreadInput, |
| 25 | 30 | ModelSelection, |
| 26 | 31 | } from "@t3tools/contracts"; |
| ... | ... | @@ -43,6 +48,11 @@ import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 43 | 48 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 44 | 49 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 45 | 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 | 56 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; |
| 47 | 57 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; |
| 48 | 58 | import { |
| ... | ... | @@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( |
| 778 | 788 | ); |
| 779 | 789 | |
| 780 | 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 | 905 | it.effect("status includes draft PR metadata when branch already has a draft PR", () => |
| 782 | 906 | Effect.gen(function* () { |
| 783 | 907 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+38-13| ... | ... | @@ -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 | 598 | const state = { |
| 599 | 599 | currentLocalStatus: baseLocalStatus, |
| 600 | 600 | currentRemoteStatus: baseRemoteStatus, |
| ... | ... | @@ -608,18 +608,25 @@ describe("VcsStatusBroadcaster", () => { |
| 608 | 608 | const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; |
| 609 | 609 | const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 610 | 610 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 611 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { | |
| 612 | if (event._tag === "snapshot") { | |
| 613 | return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); | |
| 614 | } | |
| 615 | if (event._tag === "remoteUpdated") { | |
| 616 | return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); | |
| 617 | } | |
| 618 | return Effect.void; | |
| 619 | }).pipe(Effect.forkScoped); | |
| 611 | yield* Stream.runForEach( | |
| 612 | broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), | |
| 613 | (event) => { | |
| 614 | if (event._tag === "snapshot") { | |
| 615 | return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); | |
| 616 | } | |
| 617 | if (event._tag === "remoteUpdated") { | |
| 618 | return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); | |
| 619 | } | |
| 620 | return Effect.void; | |
| 621 | }, | |
| 622 | ).pipe(Effect.forkScoped); | |
| 620 | 623 | |
| 621 | 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 | 630 | const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); |
| 624 | 631 | |
| 625 | 632 | assert.deepStrictEqual(snapshot, { |
| ... | ... | @@ -629,8 +636,16 @@ describe("VcsStatusBroadcaster", () => { |
| 629 | 636 | } satisfies VcsStatusStreamEvent); |
| 630 | 637 | assert.deepStrictEqual(remoteUpdated, { |
| 631 | 638 | _tag: "remoteUpdated", |
| 632 | remote: baseRemoteStatus, | |
| 639 | remote: remoteStatusWithPr, | |
| 633 | 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 | 649 | }).pipe(Effect.provide(layerTestFor(state))); |
| 635 | 650 | }); |
| 636 | 651 | |
| ... | ... | @@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => { |
| 1032 | 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 | 1051 | const state = { |
| 1037 | 1052 | currentLocalStatus: baseLocalStatus, |
| 1038 | 1053 | currentRemoteStatus: baseRemoteStatus, |
| ... | ... | @@ -1092,6 +1107,16 @@ describe("VcsStatusBroadcaster", () => { |
| 1092 | 1107 | const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); |
| 1093 | 1108 | const firstScope = yield* Scope.make(); |
| 1094 | 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 | 1120 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => |
| 1096 | 1121 | event._tag === "snapshot" |
| 1097 | 1122 | ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) |
apps/server/src/vcs/VcsStatusBroadcaster.ts+15-10| ... | ... | @@ -19,6 +19,7 @@ import type { |
| 19 | 19 | VcsStatusRemoteResult, |
| 20 | 20 | VcsStatusResult, |
| 21 | 21 | VcsStatusStreamEvent, |
| 22 | VcsStatusSubscriptionInput, | |
| 22 | 23 | } from "@t3tools/contracts"; |
| 23 | 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 24 | 25 | import * as KeyedLock from "@t3tools/shared/KeyedLock"; |
| ... | ... | @@ -201,7 +202,7 @@ export class VcsStatusBroadcaster extends Context.Service< |
| 201 | 202 | cwd: string, |
| 202 | 203 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; |
| 203 | 204 | readonly streamStatus: ( |
| 204 | input: VcsStatusInput, | |
| 205 | input: VcsStatusSubscriptionInput, | |
| 205 | 206 | options?: StreamStatusOptions, |
| 206 | 207 | ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>; |
| 207 | 208 | } |
| ... | ... | @@ -708,15 +709,19 @@ export const make = Effect.gen(function* () { |
| 708 | 709 | const initialLocal = yield* getOrLoadLocalStatus(cwd); |
| 709 | 710 | const cachedStatus = yield* getCachedStatus(cwd); |
| 710 | 711 | const initialRemote = cachedStatus?.remote?.value ?? null; |
| 711 | yield* retainRemotePoller( | |
| 712 | cwd, | |
| 713 | input.cwd, | |
| 714 | options?.automaticRemoteRefreshInterval ?? | |
| 715 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), | |
| 716 | cachedStatus?.remote === null || cachedStatus?.remote === undefined, | |
| 717 | ); | |
| 718 | ||
| 719 | const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); | |
| 712 | if (input.includeRemote !== false) { | |
| 713 | yield* retainRemotePoller( | |
| 714 | cwd, | |
| 715 | input.cwd, | |
| 716 | options?.automaticRemoteRefreshInterval ?? | |
| 717 | Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), | |
| 718 | cachedStatus?.remote === null || cachedStatus?.remote === undefined, | |
| 719 | ); | |
| 720 | } | |
| 721 | const release = | |
| 722 | input.includeRemote === false | |
| 723 | ? Effect.void | |
| 724 | : releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); | |
| 720 | 725 | |
| 721 | 726 | return Stream.concat( |
| 722 | 727 | Stream.make({ |
apps/web/src/components/Sidebar.tsx+2-2| ... | ... | @@ -1209,7 +1209,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1209 | 1209 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| 1210 | 1210 | ? vcsEnvironment.status({ |
| 1211 | 1211 | environmentId: thread.environmentId, |
| 1212 | input: { cwd: gitCwd }, | |
| 1212 | input: { cwd: gitCwd, includeRemote: false }, | |
| 1213 | 1213 | }) |
| 1214 | 1214 | : null, |
| 1215 | 1215 | ); |
| ... | ... | @@ -2204,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2204 | 2204 | leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null |
| 2205 | 2205 | ? vcsEnvironment.status({ |
| 2206 | 2206 | environmentId: thread.environmentId, |
| 2207 | input: { cwd: gitCwd }, | |
| 2207 | input: { cwd: gitCwd, includeRemote: false }, | |
| 2208 | 2208 | }) |
| 2209 | 2209 | : null, |
| 2210 | 2210 | ); |
apps/web/src/components/ThreadStatusIndicators.tsx+1-1| ... | ... | @@ -910,7 +910,7 @@ export function ThreadRowLeadingStatus({ |
| 910 | 910 | gitCwd !== null |
| 911 | 911 | ? vcsEnvironment.status({ |
| 912 | 912 | environmentId: thread.environmentId, |
| 913 | input: { cwd: gitCwd }, | |
| 913 | input: { cwd: gitCwd, includeRemote: false }, | |
| 914 | 914 | }) |
| 915 | 915 | : null, |
| 916 | 916 | ); |
apps/web/src/lib/backgroundActivityReporter.test.ts+9| ... | ... | @@ -22,6 +22,12 @@ describe("wasRecentlyInteracted", () => { |
| 22 | 22 | Effect.gen(function* () { |
| 23 | 23 | const environmentId = EnvironmentId.make("environment-observation-test"); |
| 24 | 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 | 31 | const release = yield* observeBackgroundActivitySubscription({ |
| 26 | 32 | environmentId, |
| 27 | 33 | method: WS_METHODS.subscribeVcsStatus, |
| ... | ... | @@ -30,6 +36,9 @@ describe("wasRecentlyInteracted", () => { |
| 30 | 36 | |
| 31 | 37 | expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); |
| 32 | 38 | |
| 39 | yield* releasePassive; | |
| 40 | expect(retainedBackgroundScopes(environmentId)).toEqual([scope]); | |
| 41 | ||
| 33 | 42 | yield* release; |
| 34 | 43 | expect(retainedBackgroundScopes(environmentId)).toEqual([]); |
| 35 | 44 | }), |
apps/web/src/lib/backgroundActivityReporter.ts+2-1| ... | ... | @@ -118,7 +118,8 @@ function scopeForSubscription( |
| 118 | 118 | if (observation.method !== WS_METHODS.subscribeVcsStatus) { |
| 119 | 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 | 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 | 111 | }); |
| 112 | 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 | 121 | export const VcsPullInput = Schema.Struct({ |
| 115 | 122 | cwd: TrimmedNonEmptyStringSchema, |
| 116 | 123 | }); |
packages/contracts/src/rpc.ts+2-1| ... | ... | @@ -121,6 +121,7 @@ import { |
| 121 | 121 | GitResolvePullRequestResult, |
| 122 | 122 | GitRunStackedActionInput, |
| 123 | 123 | VcsStatusInput, |
| 124 | VcsStatusSubscriptionInput, | |
| 124 | 125 | VcsStatusResult, |
| 125 | 126 | VcsStatusStreamEvent, |
| 126 | 127 | } from "./git.ts"; |
| ... | ... | @@ -1236,7 +1237,7 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, |
| 1236 | 1237 | }); |
| 1237 | 1238 | |
| 1238 | 1239 | const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { |
| 1239 | payload: VcsStatusInput, | |
| 1240 | payload: VcsStatusSubscriptionInput, | |
| 1240 | 1241 | success: VcsStatusStreamEvent, |
| 1241 | 1242 | error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), |
| 1242 | 1243 | stream: true, |