| author | |
| committer | |
| log | 5cc369b7eb882dbea8d5ad21ba688c73a0058748 |
| tree | 943662732e652ba61d76075d7026d07804f1892b |
| parent | f559fe0ba6fb5950bd14a2404f10b9c94b33f696 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 86 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; | 86 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; |
| 87 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; | 87 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 88 | import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; | 88 | import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; |
| 89 | import * as PullRequestService from "../src/pullRequest/PullRequestService.ts"; | ||
| 89 | 90 | ||
| 90 | const decodeCodexSettings = Schema.decodeEffect(CodexSettings); | 91 | const decodeCodexSettings = Schema.decodeEffect(CodexSettings); |
| 91 | 92 | ||
| ... | @@ -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-only | 82 | // 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 a | 86 | // 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"; |
| 62 | import { ServerConfig } from "../../config.ts"; | 62 | import { ServerConfig } from "../../config.ts"; |
| 63 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; | 63 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; |
| 64 | import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; | 64 | import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; |
| 65 | import { PullRequestService } from "../../pullRequest/PullRequestService.ts"; | ||
| 65 | 66 | ||
| 66 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); | 67 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); |
| 67 | const asTurnId = (value: string): TurnId => TurnId.make(value); | 68 | const 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 | } |
| 471 | 476 | ||
| ... | @@ -766,6 +771,14 @@ describe("CheckpointReactor", () => { | ... | @@ -766,6 +771,14 @@ describe("CheckpointReactor", () => { |
| 766 | 771 | ||
| 767 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); | 772 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 768 | 773 | ||
| 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([]); | ||
| 785 | 799 | ||
| 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 | }); |
| 805 | 820 | ||
| 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"; |
| 38 | import { isGitRepository } from "../../git/Utils.ts"; | 38 | import { isGitRepository } from "../../git/Utils.ts"; |
| 39 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | 39 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 40 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; | 40 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; |
| 41 | import * as PullRequestService from "../../pullRequest/PullRequestService.ts"; | ||
| 41 | 42 | ||
| 42 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 43 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 43 | 44 | ||
| ... | @@ -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>(); | ||
| 91 | 95 | ||
| 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* () { |
| 854 | 858 | ||
| 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 | } |
| 904 | 924 | ||
| 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* () { |
| 963 | 1000 | ||
| 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 | ); |
| 2648 | 2648 | ||
| 2649 | it.effect("an explicit invalidation makes the next listing ask the host again", () => | 2649 | it.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); |
| 2674 | 2675 | ||
| 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 | ); |
| 2683 | 2687 | ||
| ... | @@ -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 | ); |
| 3902 | 3906 | ||
| 3903 | it.effect("forgets the cached detail after a rewrite, like the other mutations", () => | 3907 | it.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 | ); |
| 3942 | 3949 |
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"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | import type * as Scope from "effect/Scope"; | 11 | import type * as Scope from "effect/Scope"; |
| 12 | import * as Stream from "effect/Stream"; | 12 | import * as Stream from "effect/Stream"; |
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 13 | import { | 14 | import { |
| 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.Scope | 150 | 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 |
| 531 | 534 | ||
| 532 | export const make = Effect.gen(function* () { | 535 | export 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 | }; |
| 2398 | 2404 | ||
| 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 must | 2410 | // 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 its | 2411 | // 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"; |
| 64 | import { useEnvironments } from "~/state/environments"; | 64 | import { useEnvironments } from "~/state/environments"; |
| 65 | import { useEnvironmentQuery } from "~/state/query"; | 65 | import { useEnvironmentQuery } from "~/state/query"; |
| 66 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 66 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 67 | import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; | 67 | import { |
| 68 | pullRequestEnvironment, | ||
| 69 | usePullRequestTurnRefresh, | ||
| 70 | useSharedPullRequestSummary, | ||
| 71 | } from "~/state/pullRequests"; | ||
| 68 | import { useAtomCommand } from "~/state/use-atom-command"; | 72 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 69 | import { vcsEnvironment } from "~/state/vcs"; | 73 | import { vcsEnvironment } from "~/state/vcs"; |
| 70 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 74 | import { 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 by | 705 | // 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 pull | 706 | // 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 is | 715 | // 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. The | 716 | // 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 run | 717 | // 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"; |
| 140 | import { useAtomCommand } from "../state/use-atom-command"; | 141 | import { 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 | }; |
| 1093 | 1100 | ||
| 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 check | 1113 | // 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 a | 1114 | // 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 stop | 1115 | // 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 { |
| 24 | import { formatEnvironmentQueryError } from "./query"; | 24 | import { formatEnvironmentQueryError } from "./query"; |
| 25 | 25 | ||
| 26 | export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); | 26 | export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime); |
| 27 | export const linkedPullRequestDetailAtom = | 27 | export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily( |
| 28 | createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | 28 | connectionAtomRuntime, |
| 29 | pullRequestEnvironment.refreshes, | ||
| 30 | ); | ||
| 29 | 31 | ||
| 30 | const observedPullRequestSummaryAtom = Atom.family((key: string) => | 32 | const 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 | ); |
| 150 | 152 | ||
| 153 | const usePullRequestTurnRefreshQuery = createMergedEnvironmentQuery( | ||
| 154 | "web-pull-requests:turn-refreshes", | ||
| 155 | ({ environmentId }: EnvironmentQueryTarget<Readonly<Record<string, never>>>) => | ||
| 156 | pullRequestEnvironment.refreshes({ environmentId, input: {} }), | ||
| 157 | ); | ||
| 158 | |||
| 159 | export function usePullRequestTurnRefreshes( | ||
| 160 | environmentIds: ReadonlyArray<EnvironmentId>, | ||
| 161 | ): ReadonlyArray<readonly [EnvironmentId, number]> { | ||
| 162 | return usePullRequestTurnRefreshQuery( | ||
| 163 | environmentIds.map((environmentId) => ({ environmentId, input: {} })), | ||
| 164 | ).values; | ||
| 165 | } | ||
| 166 | |||
| 167 | export function usePullRequestTurnRefresh(environmentId: EnvironmentId): number | null { | ||
| 168 | const result = useAtomValue(pullRequestEnvironment.refreshes({ environmentId, input: {} })); | ||
| 169 | return Option.getOrNull(AsyncResult.value(result)); | ||
| 170 | } | ||
| 171 | |||
| 151 | export interface MergedPullRequestListView { | 172 | export 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.subscribePreviewEvents | 52 | | typeof WS_METHODS.subscribePreviewEvents |
| 53 | | typeof WS_METHODS.subscribeDiscoveredLocalServers | 53 | | typeof WS_METHODS.subscribeDiscoveredLocalServers |
| 54 | | typeof WS_METHODS.subscribeResourceTelemetry | 54 | | typeof WS_METHODS.subscribeResourceTelemetry |
| 55 | | typeof WS_METHODS.pullRequestsSubscribeRefreshes | ||
| 55 | | typeof WS_METHODS.previewAutomationConnect | 56 | | typeof WS_METHODS.previewAutomationConnect |
| 56 | | typeof WS_METHODS.subscribeVcsStatus | 57 | | 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 @@ |
| 1 | import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts"; |
| 2 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Latch from "effect/Latch"; | ||
| 4 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import * as PubSub from "effect/PubSub"; | ||
| 6 | import * as Stream from "effect/Stream"; | 8 | import * as Stream from "effect/Stream"; |
| 7 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 9 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 8 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | 10 | import { 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 { |
| 42 | it.effect("refreshes pull request activity after a comment is updated", () => | 44 | it.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"; |
| 20 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; | 21 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; |
| ... | @@ -34,9 +35,19 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( | ... | @@ -34,9 +35,19 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( |
| 34 | 35 | ||
| 35 | export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; | 36 | export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; |
| 36 | 37 | ||
| 38 | function 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. */ |
| 38 | export function createLinkedPullRequestSummaryAtomFamily<R, E>( | 48 | export 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 | } |
| 49 | 61 | ||
| ... | @@ -69,6 +81,7 @@ export function pullRequestDetailToVcsStatus( | ... | @@ -69,6 +81,7 @@ export function pullRequestDetailToVcsStatus( |
| 69 | export function createPullRequestEnvironmentAtoms<R, E>( | 81 | export 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 the | 106 | * 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 | } |
| 55 | 59 | ||
| 56 | interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> { | 60 | interface 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 === undefined | 573 | options.refreshIntervalMs === undefined |
| 570 | ? queryAtom | 574 | ? 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 === undefined | 637 | ...(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 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; | 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | 4 | import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 5 | import { | 5 | import { |
| 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 | }); |
| 716 | 717 | ||
| 718 | export 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 wanted | 729 | * 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 request | 730 | * 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, |