authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-04 17:46:12-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:46:12-04:00
log98a29cbaa1ccf8d8afb6d35e3e1d925ff9b5fa90
tree94d043a9376b756e81ad9f65ea54a711aaf5358c
parent91c66ac43ddad4f8697887009746f4de11736cb9
signature Signed by PGP key B5690EEEBB952194

fix: address usage limits and merge settlement regressions (#9784)


9 files changed, 308 insertions(+), 93 deletions(-)

apps/mobile/src/connection/runtime.ts+4-1
......@@ -30,7 +30,10 @@ type ConnectionLayerSource =
3030 | typeof mobileBackgroundActivityObserverLayer
3131 | typeof mobileBackgroundActivityReporterLayer;
3232
33const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
33const providedClientConnectionLayer = Layer.merge(
34 Connection.layerWithOptions({ usageLimitSources: true }),
35 snapshotLoaderLayer,
36).pipe(
3437 Layer.provideMerge(
3538 Layer.mergeAll(
3639 runtimeContextLayer,
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+42
......@@ -1,4 +1,9 @@
11// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose.
2import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
3import { vi } from "vite-plus/test";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as TestClock from "effect/testing/TestClock";
27import { ClaudeSettings } from "@t3tools/contracts";
38import * as NodeFSP from "node:fs/promises";
49import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -14,6 +19,8 @@ import {
1419 probeClaudeCapabilities,
1520} from "./ClaudeProvider.ts";
1621
22vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
23
1724const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
1825
1926it("isolates Claude capability probes without dropping workspace setting sources", () => {
......@@ -181,3 +188,38 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
181188 }).pipe(Effect.scoped),
182189 );
183190});
191
192it.effect("preserves initialized capabilities when optional usage times out", () =>
193 Effect.gen(function* () {
194 const usageStarted = yield* Deferred.make<void>();
195 let abortSignal: AbortSignal | undefined;
196 const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
197 abortSignal = options?.abortController?.signal;
198 return {
199 initializationResult: async () => ({
200 account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
201 commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
202 }),
203 usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => {
204 Deferred.doneUnsafe(usageStarted, Effect.void);
205 return new Promise(() => {});
206 },
207 } as ReturnType<typeof ClaudeSdk.query>;
208 });
209 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
210 const probe = yield* probeClaudeCapabilities(
211 decodeClaudeSettings({ binaryPath: "claude" }),
212 ).pipe(Effect.forkChild);
213 yield* Deferred.await(usageStarted);
214 yield* TestClock.adjust("4 seconds");
215 const capabilities = yield* Fiber.join(probe);
216 assert.equal(capabilities?.email, "dev@example.com");
217 assert.equal(capabilities?.subscriptionType, "pro");
218 assert.equal(capabilities?.tokenSource, "oauth");
219 assert.deepEqual(capabilities?.slashCommands, [
220 { name: "review", description: "Review changes", input: { hint: "[path]" } },
221 ]);
222 assert.equal(capabilities?.usage, undefined);
223 assert.equal(abortSignal?.aborted, true);
224 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
225);
apps/server/src/provider/Layers/ClaudeProvider.ts+33-30
......@@ -355,44 +355,47 @@ const probeClaudeCapabilities = (
355355 }),
356356 });
357357 const init = await q.initializationResult();
358 // Usage is a second control round trip on the same process; a failure
359 // there must not cost the slash commands and account we already have.
360 const usage = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET().then(
361 (response) => ({
362 rate_limits_available: response.rate_limits_available,
363 rate_limits: response.rate_limits,
364 }),
365 () => undefined,
366 );
367 const account = init.account as
368 | {
369 readonly email?: string;
370 readonly subscriptionType?: string;
371 readonly tokenSource?: string;
372 readonly apiProvider?: string;
373 }
374 | undefined;
375 return {
376 email: account?.email,
377 subscriptionType: account?.subscriptionType,
378 tokenSource: account?.tokenSource,
379 apiProvider: account?.apiProvider,
380 slashCommands: parseClaudeInitializationCommands(init.commands),
381 ...(usage ? { usage } : {}),
382 } satisfies ClaudeCapabilitiesProbe;
358 return { q, init };
383359 });
384360 }).pipe(
361 Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS),
362 Effect.flatMap(({ q, init }) =>
363 Effect.gen(function* () {
364 // Usage has its own deadline so a slow optional request cannot discard initialization.
365 const usageResult = yield* Effect.tryPromise(() =>
366 q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
367 ).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result);
368 const usage = Result.isSuccess(usageResult)
369 ? {
370 rate_limits_available: usageResult.success.rate_limits_available,
371 rate_limits: usageResult.success.rate_limits,
372 }
373 : undefined;
374 const account = init.account as
375 | {
376 readonly email?: string;
377 readonly subscriptionType?: string;
378 readonly tokenSource?: string;
379 readonly apiProvider?: string;
380 }
381 | undefined;
382 return {
383 email: account?.email,
384 subscriptionType: account?.subscriptionType,
385 tokenSource: account?.tokenSource,
386 apiProvider: account?.apiProvider,
387 slashCommands: parseClaudeInitializationCommands(init.commands),
388 ...(usage ? { usage } : {}),
389 } satisfies ClaudeCapabilitiesProbe;
390 }),
391 ),
385392 Effect.ensuring(
386393 Effect.sync(() => {
387394 if (!abort.signal.aborted) abort.abort();
388395 }),
389396 ),
390 Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
391397 Effect.result,
392 Effect.map((result) => {
393 if (Result.isFailure(result)) return undefined;
394 return Option.isSome(result.success) ? result.success.value : undefined;
395 }),
398 Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)),
396399 );
397400};
398401
apps/server/src/pullRequest/PullRequestService.test.ts+22-2
......@@ -1005,10 +1005,12 @@ it.effect("refuses an action the host never claimed it could run", () =>
10051005 }),
10061006);
10071007
1008it.effect("publishes a successful merge for immediate settlement", () =>
1008it.effect("publishes a merge for immediate settlement only after host confirmation", () =>
10091009 Effect.scoped(
10101010 Effect.gen(function* () {
10111011 const mergedAt = "2026-09-03T02:00:00.000Z";
1012 let state: "open" | "merged" = "open";
1013 let confirmationFails = false;
10121014 const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
10131015 const service = yield* makeService({
10141016 projects: [
......@@ -1016,7 +1018,17 @@ it.effect("publishes a successful merge for immediate settlement", () =>
10161018 ],
10171019 providers: [
10181020 fakeProvider("github", {
1019 runAction: () => TestClock.setTime(Date.parse(mergedAt)),
1021 getChangeRequestSummary: () =>
1022 confirmationFails
1023 ? Effect.fail(
1024 new PullRequestProviderError({
1025 provider: "github",
1026 operation: "getChangeRequestSummary",
1027 reason: "failed",
1028 detail: "HTTP 504",
1029 }),
1030 )
1031 : Effect.succeed({ ...changeRequest(1, mergedAt), state }),
10201032 }),
10211033 ],
10221034 });
......@@ -1025,6 +1037,13 @@ it.effect("publishes a successful merge for immediate settlement", () =>
10251037 Effect.forkChild({ startImmediately: true }),
10261038 );
10271039
1040 // Queueing succeeds while the host still reports an open PR.
1041 yield* service.runAction({ ...reference, action: "merge" });
1042 confirmationFails = true;
1043 yield* service.runAction({ ...reference, action: "merge" });
1044 confirmationFails = false;
1045 state = "merged";
1046 yield* TestClock.setTime(Date.parse(mergedAt));
10281047 yield* service.runAction({
10291048 ...reference,
10301049 repository: " ACME/WEB ",
......@@ -1965,6 +1984,7 @@ it.effect("refuses a merge strategy the host does not offer", () =>
19651984 review: FULL_REVIEW,
19661985 reviewers: FULL_REVIEWERS,
19671986 },
1987 getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
19681988 runAction: (input) => {
19691989 ranWith = input.mergeMethod ?? "merge";
19701990 return Effect.void;
apps/server/src/pullRequest/PullRequestService.ts+9
......@@ -2432,6 +2432,15 @@ export const make = Effect.gen(function* () {
24322432 bumpRefEpoch({ ...input, repository });
24332433 listingsEpoch = ++epochCounter;
24342434 if (input.action === "merge") {
2435 // A successful merge action can merely enqueue the PR or enable auto-merge.
2436 const confirmed = yield* summaryUncached({ ...input, repository }).pipe(
2437 Effect.catch((error) =>
2438 Effect.logWarning("failed to confirm pull request merge", { error }).pipe(
2439 Effect.as(null),
2440 ),
2441 ),
2442 );
2443 if (confirmed?.state !== "merged") return;
24352444 yield* PubSub.publish(mergedPullRequests, {
24362445 projectId: input.projectId,
24372446 repository,
apps/web/src/components/usage/UsagePage.tsx+7-7
......@@ -1,3 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";
12import type { UsageProviderKind } from "@t3tools/contracts";
23import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
34import { useMemo, useState } from "react";
......@@ -6,7 +7,7 @@ import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";
67
78import { isElectron } from "../../env";
89import { cn } from "../../lib/utils";
9import { usePrimaryEnvironmentId } from "../../state/environments";
10import { environmentPresentations } from "../../state/presentation";
1011import { serverEnvironment } from "../../state/server";
1112import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
1213import { useAtomCommand } from "../../state/use-atom-command";
......@@ -69,7 +70,7 @@ export function UsagePage() {
6970 const { days: windowDays, window } = windowSelection;
7071 const isPast24Hours = windowDays === 1;
7172 const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
72 const primaryEnvironmentId = usePrimaryEnvironmentId();
73 const presentations = useAtomValue(environmentPresentations.presentationsAtom);
7374 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
7475 reportFailure: false,
7576 });
......@@ -115,12 +116,11 @@ export function UsagePage() {
115116 });
116117 };
117118 const refreshWindow = () => {
118 // On Limits the button re-probes every provider (and usage-limit source)
119 // on the primary environment; the live snapshots then flow in over the
120 // config stream, so nothing else needs to move.
121119 if (showingLimits) {
122 if (primaryEnvironmentId) {
123 void refreshProviders({ environmentId: primaryEnvironmentId, input: {} });
120 for (const [environmentId, presentation] of presentations) {
121 if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
122 void refreshProviders({ environmentId, input: {} });
123 }
124124 }
125125 return;
126126 }
apps/web/src/connection/runtime.ts+1-1
......@@ -31,7 +31,7 @@ type ConnectionLayerSource =
3131 | typeof backgroundActivityReporterLayer;
3232
3333const providedClientConnectionLayer = Layer.merge(
34 Connection.layerWithOptions({ environmentThemes: true }),
34 Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),
3535 snapshotLoaderLayer,
3636).pipe(
3737 Layer.provideMerge(
packages/client-runtime/src/rpc/session.test.ts+168-49
......@@ -8,6 +8,7 @@ import {
88 ServerConfigStreamEvent,
99 type ServerConfigStreamEvent as ServerConfigStreamEventType,
1010 WS_METHODS,
11 UsageLimitSourceId,
1112} from "@t3tools/contracts";
1213import { describe, expect, it } from "@effect/vitest";
1314import * as Cause from "effect/Cause";
......@@ -174,6 +175,29 @@ const THEME_SERVER_CONFIG: ServerConfigType = {
174175 },
175176};
176177const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG);
178const SOURCE_SERVER_CONFIG: ServerConfigType = {
179 ...THEME_SERVER_CONFIG,
180 environment: {
181 ...THEME_SERVER_CONFIG.environment,
182 capabilities: { ...THEME_SERVER_CONFIG.environment.capabilities, usageLimitSources: true },
183 },
184};
185const SOURCE_EVENT: ServerConfigStreamEventType = {
186 version: 1,
187 type: "usageLimitSourcesUpdated",
188 payload: {
189 sources: [
190 {
191 id: UsageLimitSourceId.make("proxy"),
192 kind: "cliproxy",
193 label: "Proxy",
194 checkedAt: "2026-09-04T00:00:00Z",
195 accounts: [],
196 },
197 ],
198 },
199};
200
177201const LEGACY_SERVER_CONFIG = {
178202 ...ENCODED_SERVER_CONFIG,
179203 environment: {
......@@ -402,52 +426,133 @@ describe("RpcSessionFactory", () => {
402426 ),
403427 );
404428
405 it.effect("shares only a config subscription with the same theme opt-in", () =>
429 for (const options of [
430 { environmentThemes: true },
431 { usageLimitSources: true },
432 { environmentThemes: true, usageLimitSources: true },
433 ]) {
434 it.effect(
435 `shares only a config subscription with the same opt-ins: ${JSON.stringify(options)}`,
436 () =>
437 Effect.scoped(
438 Effect.gen(function* () {
439 const { factory, sockets } = yield* makeFactory(options);
440 const session = yield* factory.connect(PREPARED);
441 const readyFiber = yield* Effect.forkChild(session.ready);
442 const socket = yield* awaitSocket(sockets);
443 socket.open();
444 yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, options);
445 yield* Fiber.join(readyFiber);
446
447 const shared = yield* session.subscribeServerConfig(options).pipe(Stream.runHead);
448 expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } });
449 expect(
450 socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest),
451 ).toHaveLength(1);
452
453 const fallbackFiber = yield* session
454 .subscribeServerConfig({})
455 .pipe(Stream.runHead, Effect.forkChild);
456 const fallbackRequest = yield* awaitRequest(socket, 1);
457 expect(fallbackRequest).toMatchObject({
458 tag: WS_METHODS.subscribeServerConfig,
459 payload: {},
460 });
461 socket.serverMessage(
462 encodeJson({
463 _tag: "Chunk",
464 requestId: fallbackRequest.id,
465 values: [
466 {
467 version: 1,
468 type: "snapshot",
469 config: ENCODED_THEME_SERVER_CONFIG,
470 },
471 ],
472 }),
473 );
474 expect(yield* Fiber.join(fallbackFiber)).toMatchObject({
475 _tag: "Some",
476 value: { type: "snapshot" },
477 });
478 }),
479 ),
480 );
481 }
482
483 it.effect.each([
484 { usageLimitSources: true },
485 { environmentThemes: true, usageLimitSources: true },
486 ])("replays usage sources, removal, and capability downgrade with %j", (options) =>
406487 Effect.scoped(
407488 Effect.gen(function* () {
408 const { factory, sockets } = yield* makeFactory({ environmentThemes: true });
489 const { factory, sockets } = yield* makeFactory(options);
409490 const session = yield* factory.connect(PREPARED);
410 const readyFiber = yield* Effect.forkChild(session.ready);
491 const ready = yield* Effect.forkChild(session.ready);
411492 const socket = yield* awaitSocket(sockets);
412493 socket.open();
413 yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, {
414 environmentThemes: true,
415 });
416 yield* Fiber.join(readyFiber);
417
418 const shared = yield* session
419 .subscribeServerConfig({ environmentThemes: true })
420 .pipe(Stream.runHead);
421 expect(shared).toMatchObject({ _tag: "Some", value: { type: "snapshot" } });
494 yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), options);
495 yield* Fiber.join(ready);
496 const observed = yield* Queue.unbounded<ServerConfigStreamEventType>();
497 yield* session.subscribeServerConfig(options).pipe(
498 Stream.runForEach((event) => Queue.offer(observed, event)),
499 Effect.forkChild,
500 );
501 expect((yield* Queue.take(observed)).type).toBe("snapshot");
502 const themes: ServerConfigStreamEventType[] = options.environmentThemes
503 ? [{ version: 1, type: "environmentThemesUpdated", payload: { themes: [] } }]
504 : [];
505 for (const event of themes) {
506 yield* publishConfigEvents(socket, [event]);
507 expect(yield* Queue.take(observed)).toEqual(event);
508 }
509 const events: ServerConfigStreamEventType[] = [
510 SOURCE_EVENT,
511 { version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } },
512 SOURCE_EVENT,
513 { version: 1, type: "snapshot", config: THEME_SERVER_CONFIG },
514 ];
515 for (const event of events) {
516 yield* publishConfigEvents(socket, [event]);
517 expect(yield* Queue.take(observed)).toEqual(event);
518 const started = yield* Deferred.make<void>();
519 const replay = yield* session.subscribeServerConfig(options).pipe(
520 Stream.tap(() => Deferred.succeed(started, undefined)),
521 Stream.takeUntil((item) => item.type === "keybindingsUpdated"),
522 Stream.runCollect,
523 Effect.forkChild,
524 );
525 yield* Deferred.await(started);
526 // A live end marker makes a missing or stale replay event fail without a timeout.
527 const marker: ServerConfigStreamEventType = {
528 version: 1,
529 type: "keybindingsUpdated",
530 payload: { keybindings: [], issues: [] },
531 };
532 yield* publishConfigEvents(socket, [marker]);
533 expect(yield* Queue.take(observed)).toEqual(marker);
534 const replayed = Array.from(yield* Fiber.join(replay));
535 expect(replayed.slice(1)).toEqual([
536 ...themes,
537 ...(event.type === "snapshot" ? [] : [event]),
538 marker,
539 ]);
540 let projection = applyServerConfigProjection(Option.none(), {
541 version: 1,
542 type: "snapshot",
543 config: SOURCE_SERVER_CONFIG,
544 });
545 projection = applyServerConfigProjection(projection, SOURCE_EVENT);
546 for (const item of replayed) projection = applyServerConfigProjection(projection, item);
547 expect(Option.getOrThrow(projection).config.usageLimitSources).toEqual(
548 event.type === "usageLimitSourcesUpdated" && event.payload.sources.length > 0
549 ? event.payload.sources
550 : undefined,
551 );
552 }
422553 expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength(
423554 1,
424555 );
425
426 const fallbackFiber = yield* session
427 .subscribeServerConfig({})
428 .pipe(Stream.runHead, Effect.forkChild);
429 const fallbackRequest = yield* awaitRequest(socket, 1);
430 expect(fallbackRequest).toMatchObject({
431 tag: WS_METHODS.subscribeServerConfig,
432 payload: {},
433 });
434 socket.serverMessage(
435 encodeJson({
436 _tag: "Chunk",
437 requestId: fallbackRequest.id,
438 values: [
439 {
440 version: 1,
441 type: "snapshot",
442 config: ENCODED_THEME_SERVER_CONFIG,
443 },
444 ],
445 }),
446 );
447 expect(yield* Fiber.join(fallbackFiber)).toMatchObject({
448 _tag: "Some",
449 value: { type: "snapshot" },
450 });
451556 }),
452557 ),
453558 );
......@@ -546,16 +651,20 @@ describe("RpcSessionFactory", () => {
546651 ),
547652 );
548653
549 it.effect("recovers a slow subscriber after it misses theme deletion", () =>
654 it.effect("recovers a slow subscriber after it misses theme and usage-source deletion", () =>
550655 Effect.scoped(
551656 Effect.gen(function* () {
552 const { factory, sockets } = yield* makeFactory({ environmentThemes: true });
657 const { factory, sockets } = yield* makeFactory({
658 environmentThemes: true,
659 usageLimitSources: true,
660 });
553661 const session = yield* factory.connect(PREPARED);
554662 const readyFiber = yield* Effect.forkChild(session.ready);
555663 const socket = yield* awaitSocket(sockets);
556664 socket.open();
557 yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, {
665 yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), {
558666 environmentThemes: true,
667 usageLimitSources: true,
559668 });
560669 yield* Fiber.join(readyFiber);
561670
......@@ -563,7 +672,7 @@ describe("RpcSessionFactory", () => {
563672 const releaseSlowSubscriber = yield* Deferred.make<void>();
564673 let firstEvent = true;
565674 const slowSubscriber = yield* session
566 .subscribeServerConfig({ environmentThemes: true })
675 .subscribeServerConfig({ environmentThemes: true, usageLimitSources: true })
567676 .pipe(
568677 Stream.mapEffect((event) => {
569678 if (!firstEvent) return Effect.succeed(event);
......@@ -573,7 +682,7 @@ describe("RpcSessionFactory", () => {
573682 Effect.as(event),
574683 );
575684 }),
576 Stream.take(3),
685 Stream.take(4),
577686 Stream.runCollect,
578687 Effect.forkChild,
579688 );
......@@ -612,12 +721,18 @@ describe("RpcSessionFactory", () => {
612721 type: "settingsUpdated",
613722 payload: { settings: DEFAULT_SERVER_SETTINGS },
614723 }));
615 const allEvents = [...themeEvents, ...settingsEvents];
724 const sourceEvents: ServerConfigStreamEventType[] = [
725 SOURCE_EVENT,
726 { version: 1, type: "usageLimitSourcesUpdated", payload: { sources: [] } },
727 ];
728 const allEvents = [...themeEvents, ...sourceEvents, ...settingsEvents];
616729 const observedByFastSubscriber = yield* Queue.unbounded<ServerConfigStreamEventType>();
617 yield* session.subscribeServerConfig({ environmentThemes: true }).pipe(
618 Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)),
619 Effect.forkChild,
620 );
730 yield* session
731 .subscribeServerConfig({ environmentThemes: true, usageLimitSources: true })
732 .pipe(
733 Stream.runForEach((event) => Queue.offer(observedByFastSubscriber, event)),
734 Effect.forkChild,
735 );
621736 expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot");
622737 for (const event of allEvents) {
623738 yield* publishConfigEvents(socket, [event]);
......@@ -630,19 +745,23 @@ describe("RpcSessionFactory", () => {
630745 "snapshot",
631746 "snapshot",
632747 "environmentThemesUpdated",
748 "usageLimitSourcesUpdated",
633749 ]);
634750 expect(recovered[2]).toMatchObject({ payload: { themes: [] } });
751 expect(recovered[3]).toMatchObject({ payload: { sources: [] } });
635752
636753 let projection = applyServerConfigProjection(Option.none(), {
637754 version: 1,
638755 type: "snapshot",
639 config: THEME_SERVER_CONFIG,
756 config: SOURCE_SERVER_CONFIG,
640757 });
641758 projection = applyServerConfigProjection(projection, themeEvents[0]!);
759 projection = applyServerConfigProjection(projection, SOURCE_EVENT);
642760 for (const event of recovered.slice(1)) {
643761 projection = applyServerConfigProjection(projection, event);
644762 }
645763 expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined();
764 expect(Option.getOrThrow(projection).config.usageLimitSources).toBeUndefined();
646765 }),
647766 ),
648767 );
packages/client-runtime/src/rpc/session.ts+22-3
......@@ -54,6 +54,7 @@ export interface RpcSession {
5454
5555export interface RpcSessionOptions {
5656 readonly environmentThemes?: boolean;
57 readonly usageLimitSources?: boolean;
5758}
5859
5960export class RpcSessionFactory extends Context.Service<
......@@ -83,11 +84,16 @@ type EnvironmentThemesUpdatedEvent = Extract<
8384 ServerConfigStreamEvent,
8485 { readonly type: "environmentThemesUpdated" }
8586>;
87type UsageLimitSourcesUpdatedEvent = Extract<
88 ServerConfigStreamEvent,
89 { readonly type: "usageLimitSourcesUpdated" }
90>;
8691
8792interface ServerConfigReplayState {
8893 readonly projection: ServerConfigProjection;
8994 readonly revision: number;
9095 readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined;
96 readonly sourcesEvent: UsageLimitSourcesUpdatedEvent | undefined;
9197}
9298
9399interface BufferedServerConfigEvent {
......@@ -104,7 +110,11 @@ function serverConfigReplayEvents(
104110 type: "snapshot" as const,
105111 config: withoutEnvironmentThemes(state.projection.config),
106112 };
107 return state.themesEvent === undefined ? [snapshot] : [snapshot, state.themesEvent];
113 return [
114 snapshot,
115 ...(state.themesEvent === undefined ? [] : [state.themesEvent]),
116 ...(state.sourcesEvent === undefined ? [] : [state.sourcesEvent]),
117 ];
108118}
109119
110120function mapSessionRpcError(
......@@ -134,8 +144,10 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
134144 options: RpcSessionOptions = {},
135145) {
136146 const webSocketConstructor = yield* Socket.WebSocketConstructor;
137 const serverConfigInput: ServerConfigSubscriptionInput =
138 options.environmentThemes === true ? { environmentThemes: true } : {};
147 const serverConfigInput: ServerConfigSubscriptionInput = {
148 ...(options.environmentThemes === true ? { environmentThemes: true } : {}),
149 ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}),
150 };
139151
140152 const connect = Effect.fnUntraced(function* (connection: PreparedConnection) {
141153 yield* Effect.annotateCurrentSpan({
......@@ -218,6 +230,13 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* (
218230 event.config.environment.capabilities.environmentThemes !== true
219231 ? undefined
220232 : Option.getOrUndefined(current)?.themesEvent,
233 sourcesEvent:
234 event.type === "usageLimitSourcesUpdated"
235 ? event
236 : event.type === "snapshot" &&
237 event.config.environment.capabilities.usageLimitSources !== true
238 ? undefined
239 : Option.getOrUndefined(current)?.sourcesEvent,
221240 } satisfies ServerConfigReplayState;
222241 return [
223242 Option.some({ event, replay: next, revision: next.revision }),