authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 02:16:25-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 02:16:25-04:00
log5cc369b7eb882dbea8d5ad21ba688c73a0058748
tree943662732e652ba61d76075d7026d07804f1892b
parentf559fe0ba6fb5950bd14a2404f10b9c94b33f696
signature Signed by PGP key B5690EEEBB952194

fix(pull-requests): refresh data after thread turns (#9496)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

15 files changed, 229 insertions(+), 21 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+6
...@@ -86,6 +86,7 @@ import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";...@@ -86,6 +86,7 @@ import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
86import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";86import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
87import * as VcsProcess from "../src/vcs/VcsProcess.ts";87import * as VcsProcess from "../src/vcs/VcsProcess.ts";
88import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts";88import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts";
89import * as PullRequestService from "../src/pullRequest/PullRequestService.ts";
8990
90const decodeCodexSettings = Schema.decodeEffect(CodexSettings);91const decodeCodexSettings = Schema.decodeEffect(CodexSettings);
9192
...@@ -350,6 +351,11 @@ export const makeOrchestrationIntegrationHarness = (...@@ -350,6 +351,11 @@ export const makeOrchestrationIntegrationHarness = (
350 );351 );
351 const checkpointReactorLayer = CheckpointReactorLive.pipe(352 const checkpointReactorLayer = CheckpointReactorLive.pipe(
352 Layer.provideMerge(runtimeServicesLayer),353 Layer.provideMerge(runtimeServicesLayer),
354 Layer.provideMerge(
355 Layer.mock(PullRequestService.PullRequestService)({
356 refreshAfterTurn: Effect.void,
357 }),
358 ),
353 Layer.provideMerge(359 Layer.provideMerge(
354 Layer.succeed(VcsStatusBroadcaster, {360 Layer.succeed(VcsStatusBroadcaster, {
355 getStatus: () => Effect.die("getStatus should not be called in this test"),361 getStatus: () => Effect.die("getStatus should not be called in this test"),
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = {
82 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only82 // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
83 // client pressing refresh must not be told it may not look again.83 // client pressing refresh must not be told it may not look again.
84 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,84 [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
85 [WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope,
85 // The candidate list is a read like the detail beside it; asking somebody for a review is a86 // The candidate list is a read like the detail beside it; asking somebody for a review is a
86 // write like every other one.87 // write like every other one.
87 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,88 [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+15
...@@ -62,6 +62,7 @@ import { ProviderValidationError } from "../../provider/Errors.ts";...@@ -62,6 +62,7 @@ import { ProviderValidationError } from "../../provider/Errors.ts";
62import { ServerConfig } from "../../config.ts";62import { ServerConfig } from "../../config.ts";
63import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";63import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
64import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";64import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";
65import { PullRequestService } from "../../pullRequest/PullRequestService.ts";
6566
66const asProjectId = (value: string): ProjectId => ProjectId.make(value);67const asProjectId = (value: string): ProjectId => ProjectId.make(value);
67const asTurnId = (value: string): TurnId => TurnId.make(value);68const asTurnId = (value: string): TurnId => TurnId.make(value);
...@@ -325,6 +326,8 @@ describe("CheckpointReactor", () => {...@@ -325,6 +326,8 @@ describe("CheckpointReactor", () => {
325 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {326 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
326 prefix: "t3-checkpoint-reactor-test-",327 prefix: "t3-checkpoint-reactor-test-",
327 });328 });
329 const pullRequestRefreshes: number[] = [];
330 const refreshAfterTurn = Effect.sync(() => void pullRequestRefreshes.push(1));
328 const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, {331 const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, {
329 getStatus: () => Effect.die("getStatus should not be called in this test"),332 getStatus: () => Effect.die("getStatus should not be called in this test"),
330 refreshLocalStatus: (cwd: string) =>333 refreshLocalStatus: (cwd: string) =>
...@@ -355,6 +358,7 @@ describe("CheckpointReactor", () => {...@@ -355,6 +358,7 @@ describe("CheckpointReactor", () => {
355 Layer.provideMerge(projectionSnapshotLayer),358 Layer.provideMerge(projectionSnapshotLayer),
356 Layer.provideMerge(RuntimeReceiptBusLive),359 Layer.provideMerge(RuntimeReceiptBusLive),
357 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),360 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
361 Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })),
358 Layer.provideMerge(vcsStatusBroadcasterLayer),362 Layer.provideMerge(vcsStatusBroadcasterLayer),
359 Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),363 Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
360 Layer.provideMerge(364 Layer.provideMerge(
...@@ -466,6 +470,7 @@ describe("CheckpointReactor", () => {...@@ -466,6 +470,7 @@ describe("CheckpointReactor", () => {
466 provider,470 provider,
467 cwd,471 cwd,
468 drain,472 drain,
473 pullRequestRefreshes,
469 };474 };
470 }475 }
471476
...@@ -766,6 +771,14 @@ describe("CheckpointReactor", () => {...@@ -766,6 +771,14 @@ describe("CheckpointReactor", () => {
766771
767 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8");772 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8");
768773
774 harness.provider.emit({
775 type: "turn.started",
776 eventId: EventId.make("evt-turn-started-aux"),
777 provider: ProviderDriverKind.make("codex"),
778 createdAt,
779 threadId: ThreadId.make("thread-1"),
780 turnId: asTurnId("turn-aux"),
781 });
769 harness.provider.emit({782 harness.provider.emit({
770 type: "turn.completed",783 type: "turn.completed",
771 eventId: EventId.make("evt-turn-completed-aux"),784 eventId: EventId.make("evt-turn-completed-aux"),
...@@ -782,6 +795,7 @@ describe("CheckpointReactor", () => {...@@ -782,6 +795,7 @@ describe("CheckpointReactor", () => {
782 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));795 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
783 expect(midThread?.checkpoints).toHaveLength(0);796 expect(midThread?.checkpoints).toHaveLength(0);
784 expect(pullRequestRefreshCalls).toEqual([]);797 expect(pullRequestRefreshCalls).toEqual([]);
798 expect(harness.pullRequestRefreshes).toEqual([]);
785799
786 harness.provider.emit({800 harness.provider.emit({
787 type: "turn.completed",801 type: "turn.completed",
...@@ -801,6 +815,7 @@ describe("CheckpointReactor", () => {...@@ -801,6 +815,7 @@ describe("CheckpointReactor", () => {
801 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);815 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
802 await harness.drain();816 await harness.drain();
803 expect(pullRequestRefreshCalls).toEqual([harness.cwd]);817 expect(pullRequestRefreshCalls).toEqual([harness.cwd]);
818 expect(harness.pullRequestRefreshes).toEqual([1]);
804 });819 });
805820
806 it("captures pre-turn and completion checkpoints for claude runtime events", async () => {821 it("captures pre-turn and completion checkpoints for claude runtime events", async () => {
apps/server/src/orchestration/Layers/CheckpointReactor.ts+45-3
...@@ -38,6 +38,7 @@ import type { OrchestrationDispatchError } from "../Errors.ts";...@@ -38,6 +38,7 @@ import type { OrchestrationDispatchError } from "../Errors.ts";
38import { isGitRepository } from "../../git/Utils.ts";38import { isGitRepository } from "../../git/Utils.ts";
39import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";39import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
40import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";40import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
41import * as PullRequestService from "../../pullRequest/PullRequestService.ts";
4142
42const nowIso = Effect.map(DateTime.now, DateTime.formatIso);43const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
4344
...@@ -88,6 +89,9 @@ const make = Effect.gen(function* () {...@@ -88,6 +89,9 @@ const make = Effect.gen(function* () {
88 const receiptBus = yield* RuntimeReceiptBus;89 const receiptBus = yield* RuntimeReceiptBus;
89 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;90 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
90 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;91 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
92 const pullRequests = yield* PullRequestService.PullRequestService;
93 const startedTurns = new Map<ThreadId, TurnId>();
94 const pending = new Set<ThreadId>();
9195
92 const appendRevertFailureActivity = (input: {96 const appendRevertFailureActivity = (input: {
93 readonly threadId: ThreadId;97 readonly threadId: ThreadId;
...@@ -854,6 +858,7 @@ const make = Effect.gen(function* () {...@@ -854,6 +858,7 @@ const make = Effect.gen(function* () {
854858
855 const processDomainEvent = Effect.fn("processDomainEvent")(function* (event: OrchestrationEvent) {859 const processDomainEvent = Effect.fn("processDomainEvent")(function* (event: OrchestrationEvent) {
856 if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") {860 if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") {
861 if (event.type === "thread.turn-start-requested") pending.add(event.payload.threadId);
857 yield* ensurePreTurnBaselineFromDomainTurnStart(event);862 yield* ensurePreTurnBaselineFromDomainTurnStart(event);
858 return;863 return;
859 }864 }
...@@ -897,14 +902,46 @@ const make = Effect.gen(function* () {...@@ -897,14 +902,46 @@ const make = Effect.gen(function* () {
897 const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* (902 const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* (
898 event: ProviderRuntimeEvent,903 event: ProviderRuntimeEvent,
899 ) {904 ) {
905 if (event.type === "session.exited") {
906 startedTurns.delete(event.threadId);
907 pending.delete(event.threadId);
908 return;
909 }
910
900 if (event.type === "turn.started") {911 if (event.type === "turn.started") {
912 const turnId = toTurnId(event.turnId);
913 const activeTurnId = (yield* providerService.listSessions()).find((session) =>
914 sameId(session.threadId, event.threadId),
915 )?.activeTurnId;
916 const mayReplace = pending.has(event.threadId) && sameId(activeTurnId, turnId);
917 if (turnId !== null && (!startedTurns.has(event.threadId) || mayReplace)) {
918 startedTurns.set(event.threadId, turnId);
919 pending.delete(event.threadId);
920 }
901 yield* ensurePreTurnBaselineFromTurnStart(event);921 yield* ensurePreTurnBaselineFromTurnStart(event);
902 return;922 return;
903 }923 }
904924
905 if (event.type === "turn.completed") {925 if (event.type === "turn.completed" || event.type === "turn.aborted") {
906 const turnId = toTurnId(event.turnId);926 const turnId = toTurnId(event.turnId);
907 yield* refreshLocalGitStatusFromTurnCompletion(event);927 const thread = yield* resolveThreadDetail(event.threadId);
928 const startedTurnId = startedTurns.get(event.threadId);
929 const isTrackedTurn = sameId(startedTurnId, turnId);
930 if (isTrackedTurn) startedTurns.delete(event.threadId);
931 if (event.type === "turn.completed") {
932 yield* refreshLocalGitStatusFromTurnCompletion(event);
933 }
934 if (
935 turnId !== null &&
936 thread !== undefined &&
937 (isTrackedTurn ||
938 sameId(thread.session?.activeTurnId, turnId) ||
939 (startedTurnId === undefined && !thread.session?.activeTurnId))
940 ) {
941 pending.delete(event.threadId);
942 yield* pullRequests.refreshAfterTurn;
943 }
944 if (event.type === "turn.aborted") return;
908 yield* captureCheckpointFromTurnCompletion(event).pipe(945 yield* captureCheckpointFromTurnCompletion(event).pipe(
909 Effect.catch((error) =>946 Effect.catch((error) =>
910 Effect.flatMap(nowIso, (createdAt) =>947 Effect.flatMap(nowIso, (createdAt) =>
...@@ -963,7 +1000,12 @@ const make = Effect.gen(function* () {...@@ -963,7 +1000,12 @@ const make = Effect.gen(function* () {
9631000
964 yield* forkParked(1001 yield* forkParked(
965 Stream.runForEach(providerService.streamEvents, (event) => {1002 Stream.runForEach(providerService.streamEvents, (event) => {
966 if (event.type !== "turn.started" && event.type !== "turn.completed") {1003 if (
1004 event.type !== "turn.started" &&
1005 event.type !== "turn.completed" &&
1006 event.type !== "turn.aborted" &&
1007 event.type !== "session.exited"
1008 ) {
967 return Effect.void;1009 return Effect.void;
968 }1010 }
969 return worker.enqueue({ source: "runtime", event });1011 return worker.enqueue({ source: "runtime", event });
apps/server/src/pullRequest/PullRequestService.test.ts+13-6
...@@ -2646,10 +2646,11 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>...@@ -2646,10 +2646,11 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
2646 }),2646 }),
2647);2647);
26482648
2649it.effect("an explicit invalidation makes the next listing ask the host again", () =>2649it.effect("explicit and turn invalidations make the next listing ask the host again", () =>
2650 Effect.gen(function* () {2650 Effect.gen(function* () {
2651 let hostCalls = 0;2651 let hostCalls = 0;
2652 let viewerCalls = 0;2652 let viewerCalls = 0;
2653 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
2653 const service = yield* makeService({2654 const service = yield* makeService({
2654 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],2655 projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
2655 providers: [2656 providers: [
...@@ -2673,11 +2674,14 @@ it.effect("an explicit invalidation makes the next listing ask the host again",...@@ -2673,11 +2674,14 @@ it.effect("an explicit invalidation makes the next listing ask the host again",
2673 assert.strictEqual(viewerCalls, 2);2674 assert.strictEqual(viewerCalls, 2);
26742675
2675 // Forgetting one change request leaves the listings shared.2676 // Forgetting one change request leaves the listings shared.
2676 yield* service.invalidate({2677 yield* service.invalidate({ reference });
2677 reference: { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 },
2678 });
2679 yield* service.list({ state: "open" });2678 yield* service.list({ state: "open" });
2680 assert.strictEqual(hostCalls, 2);2679 assert.strictEqual(hostCalls, 2);
2680 yield* service.refreshAfterTurn;
2681 const refresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes));
2682 yield* service.list({ state: "open" });
2683 assert.isAbove(refresh, 0);
2684 assert.strictEqual(hostCalls, 3);
2681 }),2685 }),
2682);2686);
26832687
...@@ -3900,7 +3904,7 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () =>...@@ -3900,7 +3904,7 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () =>
3900 }),3904 }),
3901);3905);
39023906
3903it.effect("forgets the cached detail after a rewrite, like the other mutations", () =>3907it.effect("forgets the cached detail after a rewrite or terminal turn", () =>
3904 Effect.gen(function* () {3908 Effect.gen(function* () {
3905 let coreCalls = 0;3909 let coreCalls = 0;
3906 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };3910 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
...@@ -3935,8 +3939,11 @@ it.effect("forgets the cached detail after a rewrite, like the other mutations",...@@ -3935,8 +3939,11 @@ it.effect("forgets the cached detail after a rewrite, like the other mutations",
3935 yield* service.detail(reference);3939 yield* service.detail(reference);
3936 yield* service.update({ ...reference, title: "Renamed" });3940 yield* service.update({ ...reference, title: "Renamed" });
3937 yield* service.detail(reference);3941 yield* service.detail(reference);
3938
3939 assert.strictEqual(coreCalls, 2);3942 assert.strictEqual(coreCalls, 2);
3943
3944 yield* service.refreshAfterTurn;
3945 yield* service.detail(reference);
3946 assert.strictEqual(coreCalls, 3);
3940 }),3947 }),
3941);3948);
39423949
apps/server/src/pullRequest/PullRequestService.ts+16-1
...@@ -10,6 +10,7 @@ import * as PubSub from "effect/PubSub";...@@ -10,6 +10,7 @@ import * as PubSub from "effect/PubSub";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import type * as Scope from "effect/Scope";11import type * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";12import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
13import {14import {
14 PullRequestOperationError,15 PullRequestOperationError,
15 PullRequestUnavailableError,16 PullRequestUnavailableError,
...@@ -148,6 +149,8 @@ export class PullRequestService extends Context.Service<...@@ -148,6 +149,8 @@ export class PullRequestService extends Context.Service<
148 never,149 never,
149 Scope.Scope150 Scope.Scope
150 >;151 >;
152 readonly subscribeRefreshes: Stream.Stream<number>;
153 readonly refreshAfterTurn: Effect.Effect<void>;
151 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;154 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
152 readonly activity: (155 readonly activity: (
153 input: PullRequestRef,156 input: PullRequestRef,
...@@ -531,6 +534,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string...@@ -531,6 +534,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string
531534
532export const make = Effect.gen(function* () {535export const make = Effect.gen(function* () {
533 const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);536 const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);
537 const pullRequestRefreshes = yield* SubscriptionRef.make(0);
534 const registry = yield* PullRequestProviderRegistry;538 const registry = yield* PullRequestProviderRegistry;
535 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;539 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
536 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;540 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
...@@ -2123,10 +2127,12 @@ export const make = Effect.gen(function* () {...@@ -2123,10 +2127,12 @@ export const make = Effect.gen(function* () {
2123 // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has.2127 // scope re-entering `refEpochs` after eviction can never mint a key an old entry still has.
2124 let epochCounter = 0;2128 let epochCounter = 0;
2125 let listingsEpoch = 0;2129 let listingsEpoch = 0;
2130 let turnRefreshEpoch = 0;
2126 const refEpochs = new Map<string, number>();2131 const refEpochs = new Map<string, number>();
2127 const REF_EPOCH_CAPACITY = 2_048;2132 const REF_EPOCH_CAPACITY = 2_048;
2128 const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;2133 const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;
2129 const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0;2134 const refEpoch = (ref: PullRequestRef) =>
2135 Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
2130 const refCacheKey = (ref: PullRequestRef) =>2136 const refCacheKey = (ref: PullRequestRef) =>
2131 JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]);2137 JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]);
2132 const bumpRefEpoch = (ref: PullRequestRef) => {2138 const bumpRefEpoch = (ref: PullRequestRef) => {
...@@ -2396,6 +2402,11 @@ export const make = Effect.gen(function* () {...@@ -2396,6 +2402,11 @@ export const make = Effect.gen(function* () {
2396 }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights)));2402 }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights)));
2397 };2403 };
23982404
2405 const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => {
2406 turnRefreshEpoch = listingsEpoch = ++epochCounter;
2407 return SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch);
2408 });
2409
2399 // A mutation's own client re-reads right after it, and every other client's next read must2410 // A mutation's own client re-reads right after it, and every other client's next read must
2400 // see the action too — so a write forgets the change request it touched and the listings its2411 // see the action too — so a write forgets the change request it touched and the listings its
2401 // state change reorders, for everyone, without any client asking.2412 // state change reorders, for everyone, without any client asking.
...@@ -2435,6 +2446,10 @@ export const make = Effect.gen(function* () {...@@ -2435,6 +2446,10 @@ export const make = Effect.gen(function* () {
2435 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(2446 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
2436 Effect.map((subscription) => Stream.fromSubscription(subscription)),2447 Effect.map((subscription) => Stream.fromSubscription(subscription)),
2437 ),2448 ),
2449 subscribeRefreshes: SubscriptionRef.changes(pullRequestRefreshes).pipe(
2450 Stream.filter((revision) => revision > 0),
2451 ),
2452 refreshAfterTurn,
2438 detail,2453 detail,
2439 activity,2454 activity,
2440 threadComments,2455 threadComments,
apps/server/src/ws.ts+6
...@@ -2096,6 +2096,12 @@ const makeWsRpcLayer = (...@@ -2096,6 +2096,12 @@ const makeWsRpcLayer = (
2096 observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {2096 observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {
2097 "rpc.aggregate": "pull-requests",2097 "rpc.aggregate": "pull-requests",
2098 }),2098 }),
2099 [WS_METHODS.pullRequestsSubscribeRefreshes]: () =>
2100 observeRpcStream(
2101 WS_METHODS.pullRequestsSubscribeRefreshes,
2102 pullRequests.subscribeRefreshes,
2103 { "rpc.aggregate": "pull-requests" },
2104 ),
2099 [WS_METHODS.pullRequestsReviewerCandidates]: (input) =>2105 [WS_METHODS.pullRequestsReviewerCandidates]: (input) =>
2100 observeRpcEffect(2106 observeRpcEffect(
2101 WS_METHODS.pullRequestsReviewerCandidates,2107 WS_METHODS.pullRequestsReviewerCandidates,
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+16-6
...@@ -64,7 +64,11 @@ import { useProjects } from "~/state/entities";...@@ -64,7 +64,11 @@ import { useProjects } from "~/state/entities";
64import { useEnvironments } from "~/state/environments";64import { useEnvironments } from "~/state/environments";
65import { useEnvironmentQuery } from "~/state/query";65import { useEnvironmentQuery } from "~/state/query";
66import { useLiveRefresh } from "~/hooks/useLiveRefresh";66import { useLiveRefresh } from "~/hooks/useLiveRefresh";
67import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests";67import {
68 pullRequestEnvironment,
69 usePullRequestTurnRefresh,
70 useSharedPullRequestSummary,
71} from "~/state/pullRequests";
68import { useAtomCommand } from "~/state/use-atom-command";72import { useAtomCommand } from "~/state/use-atom-command";
69import { vcsEnvironment } from "~/state/vcs";73import { vcsEnvironment } from "~/state/vcs";
70import { formatRelativeTimeLabel } from "~/timestampFormat";74import { formatRelativeTimeLabel } from "~/timestampFormat";
...@@ -577,6 +581,7 @@ export function PullRequestDetailPanel({...@@ -577,6 +581,7 @@ export function PullRequestDetailPanel({
577 const activityQuery = useEnvironmentQuery(581 const activityQuery = useEnvironmentQuery(
578 pullRequestEnvironment.activity({ environmentId, input: reference }),582 pullRequestEnvironment.activity({ environmentId, input: reference }),
579 );583 );
584 const turnRefresh = usePullRequestTurnRefresh(environmentId);
580 const [cachedDetail, setCachedDetail] = useState(() =>585 const [cachedDetail, setCachedDetail] = useState(() =>
581 readPullRequestDetailSnapshot(586 readPullRequestDetailSnapshot(
582 typeof window === "undefined" ? undefined : window.localStorage,587 typeof window === "undefined" ? undefined : window.localStorage,
...@@ -675,6 +680,8 @@ export function PullRequestDetailPanel({...@@ -675,6 +680,8 @@ export function PullRequestDetailPanel({
675 detailQuery.refresh();680 detailQuery.refresh();
676 activityQuery.refresh();681 activityQuery.refresh();
677 }, [activityQuery.refresh, detailQuery.refresh]);682 }, [activityQuery.refresh, detailQuery.refresh]);
683 const [refreshToken, setRefreshToken] = useState(0);
684 const codeRefreshToken = refreshToken + (turnRefresh ?? 0);
678 const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(685 const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(
679 null,686 null,
680 );687 );
...@@ -698,15 +705,18 @@ export function PullRequestDetailPanel({...@@ -698,15 +705,18 @@ export function PullRequestDetailPanel({
698 // revision effect above reads it only after this same pull request reports a change. Keyed by705 // revision effect above reads it only after this same pull request reports a change. Keyed by
699 // the pull request rather than by the panel, because this one panel shows a different pull706 // the pull request rather than by the panel, because this one panel shows a different pull
700 // request every time it is opened.707 // request every time it is opened.
701 useLiveRefresh(detailQuery.refresh, {708 useLiveRefresh(
702 key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`,709 () => {
703 });710 detailQuery.refresh();
711 setRefreshToken((token) => token + 1);
712 },
713 { key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}` },
714 );
704 // The button, on the other hand, goes around the server's cache rather than through it: it is715 // The button, on the other hand, goes around the server's cache rather than through it: it is
705 // the answer for a reader who can see that what they are looking at is behind. The716 // the answer for a reader who can see that what they are looking at is behind. The
706 // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run717 // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run
707 // and at worst answer from it.718 // and at worst answer from it.
708 const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });719 const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
709 const [refreshToken, setRefreshToken] = useState(0);
710 const refreshFromHost = useCallback(async () => {720 const refreshFromHost = useCallback(async () => {
711 await invalidate({ environmentId, input: { reference } });721 await invalidate({ environmentId, input: { reference } });
712 refreshDetail();722 refreshDetail();
...@@ -2352,7 +2362,7 @@ export function PullRequestDetailPanel({...@@ -2352,7 +2362,7 @@ export function PullRequestDetailPanel({
2352 fixFindingLabel={handoffLabels.fixFinding}2362 fixFindingLabel={handoffLabels.fixFinding}
2353 onFixFinding={startFixFinding}2363 onFixFinding={startFixFinding}
2354 onRefresh={refreshDetail}2364 onRefresh={refreshDetail}
2355 refreshToken={refreshToken}2365 refreshToken={codeRefreshToken}
2356 />2366 />
2357 </Suspense>2367 </Suspense>
2358 </div>2368 </div>
apps/web/src/routes/_chat.pull-requests.tsx+19
...@@ -135,6 +135,7 @@ import {...@@ -135,6 +135,7 @@ import {
135 pullRequestEnvironment,135 pullRequestEnvironment,
136 usePullRequestList,136 usePullRequestList,
137 usePullRequestListStats,137 usePullRequestListStats,
138 usePullRequestTurnRefreshes,
138 type EnvironmentQueryTarget,139 type EnvironmentQueryTarget,
139} from "../state/pullRequests";140} from "../state/pullRequests";
140import { useAtomCommand } from "../state/use-atom-command";141import { useAtomCommand } from "../state/use-atom-command";
...@@ -632,6 +633,12 @@ function PullRequestsRouteView() {...@@ -632,6 +633,12 @@ function PullRequestsRouteView() {
632 .join("|"),633 .join("|"),
633 [environmentQueries],634 [environmentQueries],
634 );635 );
636 const turnRefreshes = usePullRequestTurnRefreshes(
637 environmentQueries.map(({ environmentId }) => environmentId),
638 );
639 const turnRefreshToken = turnRefreshes
640 .map(([environmentId, revision]) => `${environmentId}:${revision}`)
641 .join("|");
635 // Page size is view state, not a URL concern: a shared link should open the first page.642 // Page size is view state, not a URL concern: a shared link should open the first page.
636 const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`;643 const scopeKey = `${environmentKey}:${assignmentKey}:${search.state}:${search.involvement}:${scopedProjectId ?? ""}:${search.host ?? ""}:${search.draft ?? ""}:${search.review ?? ""}:${search.checks ?? ""}:${search.author ?? ""}:${search.labels?.join("\u0000") ?? ""}`;
637 const filterKey = `${scopeKey}:${sentQuery}`;644 const filterKey = `${scopeKey}:${sentQuery}`;
...@@ -1091,6 +1098,18 @@ function PullRequestsRouteView() {...@@ -1091,6 +1098,18 @@ function PullRequestsRouteView() {
1091 });1098 });
1092 };1099 };
10931100
1101 const appliedTurnRefreshToken = useRef("");
1102 const refreshAfterTurn = useEffectEvent(() => {
1103 if (sentCursors !== null) refreshList();
1104 });
1105 useEffect(() => {
1106 if (turnRefreshToken.length === 0 || appliedTurnRefreshToken.current === turnRefreshToken) {
1107 return;
1108 }
1109 appliedTurnRefreshToken.current = turnRefreshToken;
1110 refreshAfterTurn();
1111 }, [turnRefreshToken]);
1112
1094 // The list goes stale the same way the detail does: somebody opens a pull request, a check1113 // The list goes stale the same way the detail does: somebody opens a pull request, a check
1095 // finishes, a branch is merged. So it reads again on the way back to the window, and once a1114 // finishes, a branch is merged. So it reads again on the way back to the window, and once a
1096 // minute while somebody is reading it. Those reads go through the server's cache and stop1115 // minute while somebody is reading it. Those reads go through the server's cache and stop
apps/web/src/state/pullRequests.ts+23-2
...@@ -24,8 +24,10 @@ import {...@@ -24,8 +24,10 @@ import {
24import { formatEnvironmentQueryError } from "./query";24import { formatEnvironmentQueryError } from "./query";
2525
26export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);26export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime);
27export const linkedPullRequestDetailAtom =27export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(
28 createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);28 connectionAtomRuntime,
29 pullRequestEnvironment.refreshes,
30);
2931
30const observedPullRequestSummaryAtom = Atom.family((key: string) =>32const observedPullRequestSummaryAtom = Atom.family((key: string) =>
31 Atom.make<PullRequestSummary | null>(null).pipe(33 Atom.make<PullRequestSummary | null>(null).pipe(
...@@ -148,6 +150,25 @@ const usePullRequestStatsQuery = createMergedEnvironmentQuery(...@@ -148,6 +150,25 @@ const usePullRequestStatsQuery = createMergedEnvironmentQuery(
148 pullRequestEnvironment.listStats,150 pullRequestEnvironment.listStats,
149);151);
150152
153const usePullRequestTurnRefreshQuery = createMergedEnvironmentQuery(
154 "web-pull-requests:turn-refreshes",
155 ({ environmentId }: EnvironmentQueryTarget<Readonly<Record<string, never>>>) =>
156 pullRequestEnvironment.refreshes({ environmentId, input: {} }),
157);
158
159export function usePullRequestTurnRefreshes(
160 environmentIds: ReadonlyArray<EnvironmentId>,
161): ReadonlyArray<readonly [EnvironmentId, number]> {
162 return usePullRequestTurnRefreshQuery(
163 environmentIds.map((environmentId) => ({ environmentId, input: {} })),
164 ).values;
165}
166
167export function usePullRequestTurnRefresh(environmentId: EnvironmentId): number | null {
168 const result = useAtomValue(pullRequestEnvironment.refreshes({ environmentId, input: {} }));
169 return Option.getOrNull(AsyncResult.value(result));
170}
171
151export interface MergedPullRequestListView {172export interface MergedPullRequestListView {
152 readonly data: MergedPullRequestList | null;173 readonly data: MergedPullRequestList | null;
153 readonly error: string | null;174 readonly error: string | null;
packages/client-runtime/src/rpc/client.ts+1
...@@ -52,6 +52,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -52,6 +52,7 @@ export type EnvironmentSubscriptionRpcTag =
52 | typeof WS_METHODS.subscribePreviewEvents52 | typeof WS_METHODS.subscribePreviewEvents
53 | typeof WS_METHODS.subscribeDiscoveredLocalServers53 | typeof WS_METHODS.subscribeDiscoveredLocalServers
54 | typeof WS_METHODS.subscribeResourceTelemetry54 | typeof WS_METHODS.subscribeResourceTelemetry
55 | typeof WS_METHODS.pullRequestsSubscribeRefreshes
55 | typeof WS_METHODS.previewAutomationConnect56 | typeof WS_METHODS.previewAutomationConnect
56 | typeof WS_METHODS.subscribeVcsStatus57 | typeof WS_METHODS.subscribeVcsStatus
57 | typeof WS_METHODS.terminalAttach;58 | typeof WS_METHODS.terminalAttach;
packages/client-runtime/src/state/pullRequests.test.ts+20
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts";1import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts";
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Latch from "effect/Latch";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as PubSub from "effect/PubSub";
6import * as Stream from "effect/Stream";8import * as Stream from "effect/Stream";
7import * as SubscriptionRef from "effect/SubscriptionRef";9import * as SubscriptionRef from "effect/SubscriptionRef";
8import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";10import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
...@@ -42,8 +44,10 @@ function session(client: WsRpcProtocolClient): RpcSession {...@@ -42,8 +44,10 @@ function session(client: WsRpcProtocolClient): RpcSession {
42it.effect("refreshes pull request activity after a comment is updated", () =>44it.effect("refreshes pull request activity after a comment is updated", () =>
43 Effect.scoped(45 Effect.scoped(
44 Effect.gen(function* () {46 Effect.gen(function* () {
47 const refreshEvents = yield* PubSub.unbounded<number>();
45 let commentBody = "old comment";48 let commentBody = "old comment";
46 const client = {49 const client = {
50 [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents),
47 [WS_METHODS.pullRequestsActivity]: () =>51 [WS_METHODS.pullRequestsActivity]: () =>
48 Effect.succeed({52 Effect.succeed({
49 author: null,53 author: null,
...@@ -138,6 +142,22 @@ it.effect("refreshes pull request activity after a comment is updated", () =>...@@ -138,6 +142,22 @@ it.effect("refreshes pull request activity after a comment is updated", () =>
138 (yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true })).comments[0]142 (yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true })).comments[0]
139 ?.body,143 ?.body,
140 ).toBe("updated");144 ).toBe("updated");
145 const refreshed = Latch.makeUnsafe();
146 const stop = registry.subscribe(activity, (result) => {
147 if (AsyncResult.isSuccess(result) && result.value.comments[0]?.body === "after turn") {
148 refreshed.openUnsafe();
149 }
150 });
151 yield* Effect.addFinalizer(() => Effect.sync(stop));
152
153 commentBody = "after turn";
154 yield* PubSub.publish(refreshEvents, 1);
155 yield* refreshed.await;
156
157 expect(
158 (yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true })).comments[0]
159 ?.body,
160 ).toBe("after turn");
141 }),161 }),
142 ),162 ),
143);163);
packages/client-runtime/src/state/pullRequests.ts+19
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 createAtomCommandScheduler,15 createAtomCommandScheduler,
16 createEnvironmentRpcCommand,16 createEnvironmentRpcCommand,
17 createEnvironmentRpcQueryAtomFamily,17 createEnvironmentRpcQueryAtomFamily,
18 createEnvironmentRpcSubscriptionAtomFamily,
18 createEnvironmentQueryAtomFamily,19 createEnvironmentQueryAtomFamily,
19} from "./runtime.ts";20} from "./runtime.ts";
20import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";21import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
...@@ -34,9 +35,19 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(...@@ -34,9 +35,19 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError(
3435
35export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000;36export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000;
3637
38function createPullRequestRefreshAtomFamily<R, E>(
39 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
40) {
41 return createEnvironmentRpcSubscriptionAtomFamily(runtime, {
42 label: "environment-data:pull-requests:turn-refreshes",
43 tag: WS_METHODS.pullRequestsSubscribeRefreshes,
44 });
45}
46
37/** Refresh only the live fields a linked thread renders. */47/** Refresh only the live fields a linked thread renders. */
38export function createLinkedPullRequestSummaryAtomFamily<R, E>(48export function createLinkedPullRequestSummaryAtomFamily<R, E>(
39 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,49 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
50 refreshes = createPullRequestRefreshAtomFamily(runtime),
40) {51) {
41 return createEnvironmentRpcQueryAtomFamily(runtime, {52 return createEnvironmentRpcQueryAtomFamily(runtime, {
42 label: "environment-data:pull-requests:linked-summary",53 label: "environment-data:pull-requests:linked-summary",
...@@ -44,6 +55,7 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(...@@ -44,6 +55,7 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>(
44 staleTimeMs: 60_000,55 staleTimeMs: 60_000,
45 refreshIntervalMs: 60_000,56 refreshIntervalMs: 60_000,
46 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,57 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
58 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
47 });59 });
48}60}
4961
...@@ -69,6 +81,7 @@ export function pullRequestDetailToVcsStatus(...@@ -69,6 +81,7 @@ export function pullRequestDetailToVcsStatus(
69export function createPullRequestEnvironmentAtoms<R, E>(81export function createPullRequestEnvironmentAtoms<R, E>(
70 runtime: Atom.AtomRuntime<EnvironmentRegistry | PullRequestDiffLoader | R, E>,82 runtime: Atom.AtomRuntime<EnvironmentRegistry | PullRequestDiffLoader | R, E>,
71) {83) {
84 const refreshes = createPullRequestRefreshAtomFamily(runtime);
72 const commandScheduler = createAtomCommandScheduler();85 const commandScheduler = createAtomCommandScheduler();
73 const serialPerEnvironment = {86 const serialPerEnvironment = {
74 mode: "serial",87 mode: "serial",
...@@ -78,12 +91,16 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -78,12 +91,16 @@ export function createPullRequestEnvironmentAtoms<R, E>(
78 label: "environment-data:pull-requests:activity",91 label: "environment-data:pull-requests:activity",
79 tag: WS_METHODS.pullRequestsActivity,92 tag: WS_METHODS.pullRequestsActivity,
80 staleTimeMs: 15_000,93 staleTimeMs: 15_000,
94 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
81 });95 });
82 return {96 return {
97 refreshes,
83 list: createEnvironmentRpcQueryAtomFamily(runtime, {98 list: createEnvironmentRpcQueryAtomFamily(runtime, {
84 label: "environment-data:pull-requests:list",99 label: "environment-data:pull-requests:list",
85 tag: WS_METHODS.pullRequestsList,100 tag: WS_METHODS.pullRequestsList,
86 staleTimeMs: 30_000,101 staleTimeMs: 30_000,
102 refreshTrigger: ({ environmentId, input }) =>
103 input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined,
87 }),104 }),
88 /**105 /**
89 * The line counts for rows the listing has already handed over. Its own query because the106 * The line counts for rows the listing has already handed over. Its own query because the
...@@ -95,11 +112,13 @@ export function createPullRequestEnvironmentAtoms<R, E>(...@@ -95,11 +112,13 @@ export function createPullRequestEnvironmentAtoms<R, E>(
95 label: "environment-data:pull-requests:list-stats",112 label: "environment-data:pull-requests:list-stats",
96 tag: WS_METHODS.pullRequestsListStats,113 tag: WS_METHODS.pullRequestsListStats,
97 staleTimeMs: 60_000,114 staleTimeMs: 60_000,
115 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
98 }),116 }),
99 detail: createEnvironmentRpcQueryAtomFamily(runtime, {117 detail: createEnvironmentRpcQueryAtomFamily(runtime, {
100 label: "environment-data:pull-requests:detail",118 label: "environment-data:pull-requests:detail",
101 tag: WS_METHODS.pullRequestsDetail,119 tag: WS_METHODS.pullRequestsDetail,
102 staleTimeMs: 15_000,120 staleTimeMs: 15_000,
121 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
103 }),122 }),
104 activity,123 activity,
105 threadComments: createEnvironmentRpcCommand(runtime, {124 threadComments: createEnvironmentRpcCommand(runtime, {
packages/client-runtime/src/state/runtime.ts+16-2
...@@ -51,6 +51,10 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOpt...@@ -51,6 +51,10 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends EnvironmentAtomOpt
51 readonly staleTimeMs?: number;51 readonly staleTimeMs?: number;
52 readonly idleTtlMs?: number;52 readonly idleTtlMs?: number;
53 readonly refreshIntervalMs?: number;53 readonly refreshIntervalMs?: number;
54 readonly refreshTrigger?: (target: {
55 readonly environmentId: EnvironmentIdType;
56 readonly input: Input;
57 }) => Atom.Atom<unknown> | undefined;
54}58}
5559
56interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> {60interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> {
...@@ -565,10 +569,15 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(...@@ -565,10 +569,15 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(
565 }),569 }),
566 Atom.setIdleTTL(idleTtlMs),570 Atom.setIdleTTL(idleTtlMs),
567 );571 );
568 return (572 const intervalQuery =
569 options.refreshIntervalMs === undefined573 options.refreshIntervalMs === undefined
570 ? queryAtom574 ? queryAtom
571 : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs))575 : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs));
576 const refreshTrigger = options.refreshTrigger?.(target);
577 return (
578 refreshTrigger === undefined
579 ? intervalQuery
580 : intervalQuery.pipe(Atom.makeRefreshOnSignal(refreshTrigger))
572 ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`));581 ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`));
573 });582 });
574 return (target) => family(environmentRpcKey(target));583 return (target) => family(environmentRpcKey(target));
...@@ -615,6 +624,10 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm...@@ -615,6 +624,10 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm
615 readonly staleTimeMs?: number;624 readonly staleTimeMs?: number;
616 readonly idleTtlMs?: number;625 readonly idleTtlMs?: number;
617 readonly refreshIntervalMs?: number;626 readonly refreshIntervalMs?: number;
627 readonly refreshTrigger?: (target: {
628 readonly environmentId: EnvironmentIdType;
629 readonly input: EnvironmentRpcInput<TTag>;
630 }) => Atom.Atom<unknown> | undefined;
618 },631 },
619) {632) {
620 return createEnvironmentQueryAtomFamily(runtime, {633 return createEnvironmentQueryAtomFamily(runtime, {
...@@ -624,6 +637,7 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm...@@ -624,6 +637,7 @@ export function createEnvironmentRpcQueryAtomFamily<R, ER, TTag extends Environm
624 ...(options.refreshIntervalMs === undefined637 ...(options.refreshIntervalMs === undefined
625 ? {}638 ? {}
626 : { refreshIntervalMs: options.refreshIntervalMs }),639 : { refreshIntervalMs: options.refreshIntervalMs }),
640 ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }),
627 execute: (input: EnvironmentRpcInput<TTag>) => request(options.tag, input),641 execute: (input: EnvironmentRpcInput<TTag>) => request(options.tag, input),
628 });642 });
629}643}
packages/contracts/src/rpc.ts+13-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import * as Rpc from "effect/unstable/rpc/Rpc";2import * as Rpc from "effect/unstable/rpc/Rpc";
3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
4import { TrimmedNonEmptyString } from "./baseSchemas.ts";4import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
5import {5import {
6 ProviderAuthCancelInput,6 ProviderAuthCancelInput,
7 ProviderAuthCompleteInput,7 ProviderAuthCompleteInput,
...@@ -343,6 +343,7 @@ export const WS_METHODS = {...@@ -343,6 +343,7 @@ export const WS_METHODS = {
343 pullRequestsSetThreadResolution: "pullRequests.setThreadResolution",343 pullRequestsSetThreadResolution: "pullRequests.setThreadResolution",
344 pullRequestsSetReaction: "pullRequests.setReaction",344 pullRequestsSetReaction: "pullRequests.setReaction",
345 pullRequestsInvalidate: "pullRequests.invalidate",345 pullRequestsInvalidate: "pullRequests.invalidate",
346 pullRequestsSubscribeRefreshes: "pullRequests.subscribeRefreshes",
346 pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates",347 pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates",
347 pullRequestsRequestReviewers: "pullRequests.requestReviewers",348 pullRequestsRequestReviewers: "pullRequests.requestReviewers",
348 pullRequestsLabelCandidates: "pullRequests.labelCandidates",349 pullRequestsLabelCandidates: "pullRequests.labelCandidates",
...@@ -714,6 +715,16 @@ export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInval...@@ -714,6 +715,16 @@ export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInval
714 error: PullRequestRpcError,715 error: PullRequestRpcError,
715});716});
716717
718export const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(
719 WS_METHODS.pullRequestsSubscribeRefreshes,
720 {
721 payload: Schema.Struct({}),
722 success: NonNegativeInt,
723 error: EnvironmentAuthorizationError,
724 stream: true,
725 },
726);
727
717/**728/**
718 * Read on its own rather than as part of the detail: the people who may be asked are only wanted729 * Read on its own rather than as part of the detail: the people who may be asked are only wanted
719 * once somebody opens the menu, and reading them with every change request would spend a request730 * once somebody opens the menu, and reading them with every change request would spend a request
...@@ -1215,6 +1226,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1215,6 +1226,7 @@ export const WsRpcGroup = RpcGroup.make(
1215 WsPullRequestsSetThreadResolutionRpc,1226 WsPullRequestsSetThreadResolutionRpc,
1216 WsPullRequestsSetReactionRpc,1227 WsPullRequestsSetReactionRpc,
1217 WsPullRequestsInvalidateRpc,1228 WsPullRequestsInvalidateRpc,
1229 WsPullRequestsSubscribeRefreshesRpc,
1218 WsPullRequestsReviewerCandidatesRpc,1230 WsPullRequestsReviewerCandidatesRpc,
1219 WsPullRequestsRequestReviewersRpc,1231 WsPullRequestsRequestReviewersRpc,
1220 WsPullRequestsLabelCandidatesRpc,1232 WsPullRequestsLabelCandidatesRpc,