| author | |
| committer | |
| log | 98a29cbaa1ccf8d8afb6d35e3e1d925ff9b5fa90 |
| tree | 94d043a9376b756e81ad9f65ea54a711aaf5358c |
| parent | 91c66ac43ddad4f8697887009746f4de11736cb9 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 308 insertions(+), 93 deletions(-)
apps/mobile/src/connection/runtime.ts+4-1| ... | ... | @@ -30,7 +30,10 @@ type ConnectionLayerSource = |
| 30 | 30 | | typeof mobileBackgroundActivityObserverLayer |
| 31 | 31 | | typeof mobileBackgroundActivityReporterLayer; |
| 32 | 32 | |
| 33 | const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe( | |
| 33 | const providedClientConnectionLayer = Layer.merge( | |
| 34 | Connection.layerWithOptions({ usageLimitSources: true }), | |
| 35 | snapshotLoaderLayer, | |
| 36 | ).pipe( | |
| 34 | 37 | Layer.provideMerge( |
| 35 | 38 | Layer.mergeAll( |
| 36 | 39 | runtimeContextLayer, |
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+42| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | 1 | // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. |
| 2 | import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; | |
| 3 | import { vi } from "vite-plus/test"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 2 | 7 | import { ClaudeSettings } from "@t3tools/contracts"; |
| 3 | 8 | import * as NodeFSP from "node:fs/promises"; |
| 4 | 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -14,6 +19,8 @@ import { |
| 14 | 19 | probeClaudeCapabilities, |
| 15 | 20 | } from "./ClaudeProvider.ts"; |
| 16 | 21 | |
| 22 | vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); | |
| 23 | ||
| 17 | 24 | const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); |
| 18 | 25 | |
| 19 | 26 | it("isolates Claude capability probes without dropping workspace setting sources", () => { |
| ... | ... | @@ -181,3 +188,38 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { |
| 181 | 188 | }).pipe(Effect.scoped), |
| 182 | 189 | ); |
| 183 | 190 | }); |
| 191 | ||
| 192 | it.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 = ( |
| 355 | 355 | }), |
| 356 | 356 | }); |
| 357 | 357 | 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 }; | |
| 383 | 359 | }); |
| 384 | 360 | }).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 | ), | |
| 385 | 392 | Effect.ensuring( |
| 386 | 393 | Effect.sync(() => { |
| 387 | 394 | if (!abort.signal.aborted) abort.abort(); |
| 388 | 395 | }), |
| 389 | 396 | ), |
| 390 | Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), | |
| 391 | 397 | 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)), | |
| 396 | 399 | ); |
| 397 | 400 | }; |
| 398 | 401 |
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", () => |
| 1005 | 1005 | }), |
| 1006 | 1006 | ); |
| 1007 | 1007 | |
| 1008 | it.effect("publishes a successful merge for immediate settlement", () => | |
| 1008 | it.effect("publishes a merge for immediate settlement only after host confirmation", () => | |
| 1009 | 1009 | Effect.scoped( |
| 1010 | 1010 | Effect.gen(function* () { |
| 1011 | 1011 | const mergedAt = "2026-09-03T02:00:00.000Z"; |
| 1012 | let state: "open" | "merged" = "open"; | |
| 1013 | let confirmationFails = false; | |
| 1012 | 1014 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; |
| 1013 | 1015 | const service = yield* makeService({ |
| 1014 | 1016 | projects: [ |
| ... | ... | @@ -1016,7 +1018,17 @@ it.effect("publishes a successful merge for immediate settlement", () => |
| 1016 | 1018 | ], |
| 1017 | 1019 | providers: [ |
| 1018 | 1020 | 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 }), | |
| 1020 | 1032 | }), |
| 1021 | 1033 | ], |
| 1022 | 1034 | }); |
| ... | ... | @@ -1025,6 +1037,13 @@ it.effect("publishes a successful merge for immediate settlement", () => |
| 1025 | 1037 | Effect.forkChild({ startImmediately: true }), |
| 1026 | 1038 | ); |
| 1027 | 1039 | |
| 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)); | |
| 1028 | 1047 | yield* service.runAction({ |
| 1029 | 1048 | ...reference, |
| 1030 | 1049 | repository: " ACME/WEB ", |
| ... | ... | @@ -1965,6 +1984,7 @@ it.effect("refuses a merge strategy the host does not offer", () => |
| 1965 | 1984 | review: FULL_REVIEW, |
| 1966 | 1985 | reviewers: FULL_REVIEWERS, |
| 1967 | 1986 | }, |
| 1987 | getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")), | |
| 1968 | 1988 | runAction: (input) => { |
| 1969 | 1989 | ranWith = input.mergeMethod ?? "merge"; |
| 1970 | 1990 | return Effect.void; |
apps/server/src/pullRequest/PullRequestService.ts+9| ... | ... | @@ -2432,6 +2432,15 @@ export const make = Effect.gen(function* () { |
| 2432 | 2432 | bumpRefEpoch({ ...input, repository }); |
| 2433 | 2433 | listingsEpoch = ++epochCounter; |
| 2434 | 2434 | 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; | |
| 2435 | 2444 | yield* PubSub.publish(mergedPullRequests, { |
| 2436 | 2445 | projectId: input.projectId, |
| 2437 | 2446 | repository, |
apps/web/src/components/usage/UsagePage.tsx+7-7| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | 3 | import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; |
| 3 | 4 | import { useMemo, useState } from "react"; |
| ... | ... | @@ -6,7 +7,7 @@ import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; |
| 6 | 7 | |
| 7 | 8 | import { isElectron } from "../../env"; |
| 8 | 9 | import { cn } from "../../lib/utils"; |
| 9 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 10 | import { environmentPresentations } from "../../state/presentation"; | |
| 10 | 11 | import { serverEnvironment } from "../../state/server"; |
| 11 | 12 | import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; |
| 12 | 13 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | ... | @@ -69,7 +70,7 @@ export function UsagePage() { |
| 69 | 70 | const { days: windowDays, window } = windowSelection; |
| 70 | 71 | const isPast24Hours = windowDays === 1; |
| 71 | 72 | const { merged, environments, isPending, isPartial, refresh } = useUsage(window); |
| 72 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 73 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); | |
| 73 | 74 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { |
| 74 | 75 | reportFailure: false, |
| 75 | 76 | }); |
| ... | ... | @@ -115,12 +116,11 @@ export function UsagePage() { |
| 115 | 116 | }); |
| 116 | 117 | }; |
| 117 | 118 | 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. | |
| 121 | 119 | 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 | } | |
| 124 | 124 | } |
| 125 | 125 | return; |
| 126 | 126 | } |
apps/web/src/connection/runtime.ts+1-1| ... | ... | @@ -31,7 +31,7 @@ type ConnectionLayerSource = |
| 31 | 31 | | typeof backgroundActivityReporterLayer; |
| 32 | 32 | |
| 33 | 33 | const providedClientConnectionLayer = Layer.merge( |
| 34 | Connection.layerWithOptions({ environmentThemes: true }), | |
| 34 | Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }), | |
| 35 | 35 | snapshotLoaderLayer, |
| 36 | 36 | ).pipe( |
| 37 | 37 | Layer.provideMerge( |
packages/client-runtime/src/rpc/session.test.ts+168-49| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | ServerConfigStreamEvent, |
| 9 | 9 | type ServerConfigStreamEvent as ServerConfigStreamEventType, |
| 10 | 10 | WS_METHODS, |
| 11 | UsageLimitSourceId, | |
| 11 | 12 | } from "@t3tools/contracts"; |
| 12 | 13 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | 14 | import * as Cause from "effect/Cause"; |
| ... | ... | @@ -174,6 +175,29 @@ const THEME_SERVER_CONFIG: ServerConfigType = { |
| 174 | 175 | }, |
| 175 | 176 | }; |
| 176 | 177 | const ENCODED_THEME_SERVER_CONFIG = encodeServerConfig(THEME_SERVER_CONFIG); |
| 178 | const 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 | }; | |
| 185 | const 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 | ||
| 177 | 201 | const LEGACY_SERVER_CONFIG = { |
| 178 | 202 | ...ENCODED_SERVER_CONFIG, |
| 179 | 203 | environment: { |
| ... | ... | @@ -402,52 +426,133 @@ describe("RpcSessionFactory", () => { |
| 402 | 426 | ), |
| 403 | 427 | ); |
| 404 | 428 | |
| 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) => | |
| 406 | 487 | Effect.scoped( |
| 407 | 488 | Effect.gen(function* () { |
| 408 | const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); | |
| 489 | const { factory, sockets } = yield* makeFactory(options); | |
| 409 | 490 | const session = yield* factory.connect(PREPARED); |
| 410 | const readyFiber = yield* Effect.forkChild(session.ready); | |
| 491 | const ready = yield* Effect.forkChild(session.ready); | |
| 411 | 492 | const socket = yield* awaitSocket(sockets); |
| 412 | 493 | 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 | } | |
| 422 | 553 | expect(socket.sent.map((message) => decodeJson(message)).filter(isRpcRequest)).toHaveLength( |
| 423 | 554 | 1, |
| 424 | 555 | ); |
| 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 | }); | |
| 451 | 556 | }), |
| 452 | 557 | ), |
| 453 | 558 | ); |
| ... | ... | @@ -546,16 +651,20 @@ describe("RpcSessionFactory", () => { |
| 546 | 651 | ), |
| 547 | 652 | ); |
| 548 | 653 | |
| 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", () => | |
| 550 | 655 | Effect.scoped( |
| 551 | 656 | Effect.gen(function* () { |
| 552 | const { factory, sockets } = yield* makeFactory({ environmentThemes: true }); | |
| 657 | const { factory, sockets } = yield* makeFactory({ | |
| 658 | environmentThemes: true, | |
| 659 | usageLimitSources: true, | |
| 660 | }); | |
| 553 | 661 | const session = yield* factory.connect(PREPARED); |
| 554 | 662 | const readyFiber = yield* Effect.forkChild(session.ready); |
| 555 | 663 | const socket = yield* awaitSocket(sockets); |
| 556 | 664 | socket.open(); |
| 557 | yield* completeInitialConfig(socket, ENCODED_THEME_SERVER_CONFIG, { | |
| 665 | yield* completeInitialConfig(socket, encodeServerConfig(SOURCE_SERVER_CONFIG), { | |
| 558 | 666 | environmentThemes: true, |
| 667 | usageLimitSources: true, | |
| 559 | 668 | }); |
| 560 | 669 | yield* Fiber.join(readyFiber); |
| 561 | 670 | |
| ... | ... | @@ -563,7 +672,7 @@ describe("RpcSessionFactory", () => { |
| 563 | 672 | const releaseSlowSubscriber = yield* Deferred.make<void>(); |
| 564 | 673 | let firstEvent = true; |
| 565 | 674 | const slowSubscriber = yield* session |
| 566 | .subscribeServerConfig({ environmentThemes: true }) | |
| 675 | .subscribeServerConfig({ environmentThemes: true, usageLimitSources: true }) | |
| 567 | 676 | .pipe( |
| 568 | 677 | Stream.mapEffect((event) => { |
| 569 | 678 | if (!firstEvent) return Effect.succeed(event); |
| ... | ... | @@ -573,7 +682,7 @@ describe("RpcSessionFactory", () => { |
| 573 | 682 | Effect.as(event), |
| 574 | 683 | ); |
| 575 | 684 | }), |
| 576 | Stream.take(3), | |
| 685 | Stream.take(4), | |
| 577 | 686 | Stream.runCollect, |
| 578 | 687 | Effect.forkChild, |
| 579 | 688 | ); |
| ... | ... | @@ -612,12 +721,18 @@ describe("RpcSessionFactory", () => { |
| 612 | 721 | type: "settingsUpdated", |
| 613 | 722 | payload: { settings: DEFAULT_SERVER_SETTINGS }, |
| 614 | 723 | })); |
| 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]; | |
| 616 | 729 | 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 | ); | |
| 621 | 736 | expect((yield* Queue.take(observedByFastSubscriber)).type).toBe("snapshot"); |
| 622 | 737 | for (const event of allEvents) { |
| 623 | 738 | yield* publishConfigEvents(socket, [event]); |
| ... | ... | @@ -630,19 +745,23 @@ describe("RpcSessionFactory", () => { |
| 630 | 745 | "snapshot", |
| 631 | 746 | "snapshot", |
| 632 | 747 | "environmentThemesUpdated", |
| 748 | "usageLimitSourcesUpdated", | |
| 633 | 749 | ]); |
| 634 | 750 | expect(recovered[2]).toMatchObject({ payload: { themes: [] } }); |
| 751 | expect(recovered[3]).toMatchObject({ payload: { sources: [] } }); | |
| 635 | 752 | |
| 636 | 753 | let projection = applyServerConfigProjection(Option.none(), { |
| 637 | 754 | version: 1, |
| 638 | 755 | type: "snapshot", |
| 639 | config: THEME_SERVER_CONFIG, | |
| 756 | config: SOURCE_SERVER_CONFIG, | |
| 640 | 757 | }); |
| 641 | 758 | projection = applyServerConfigProjection(projection, themeEvents[0]!); |
| 759 | projection = applyServerConfigProjection(projection, SOURCE_EVENT); | |
| 642 | 760 | for (const event of recovered.slice(1)) { |
| 643 | 761 | projection = applyServerConfigProjection(projection, event); |
| 644 | 762 | } |
| 645 | 763 | expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined(); |
| 764 | expect(Option.getOrThrow(projection).config.usageLimitSources).toBeUndefined(); | |
| 646 | 765 | }), |
| 647 | 766 | ), |
| 648 | 767 | ); |
packages/client-runtime/src/rpc/session.ts+22-3| ... | ... | @@ -54,6 +54,7 @@ export interface RpcSession { |
| 54 | 54 | |
| 55 | 55 | export interface RpcSessionOptions { |
| 56 | 56 | readonly environmentThemes?: boolean; |
| 57 | readonly usageLimitSources?: boolean; | |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | export class RpcSessionFactory extends Context.Service< |
| ... | ... | @@ -83,11 +84,16 @@ type EnvironmentThemesUpdatedEvent = Extract< |
| 83 | 84 | ServerConfigStreamEvent, |
| 84 | 85 | { readonly type: "environmentThemesUpdated" } |
| 85 | 86 | >; |
| 87 | type UsageLimitSourcesUpdatedEvent = Extract< | |
| 88 | ServerConfigStreamEvent, | |
| 89 | { readonly type: "usageLimitSourcesUpdated" } | |
| 90 | >; | |
| 86 | 91 | |
| 87 | 92 | interface ServerConfigReplayState { |
| 88 | 93 | readonly projection: ServerConfigProjection; |
| 89 | 94 | readonly revision: number; |
| 90 | 95 | readonly themesEvent: EnvironmentThemesUpdatedEvent | undefined; |
| 96 | readonly sourcesEvent: UsageLimitSourcesUpdatedEvent | undefined; | |
| 91 | 97 | } |
| 92 | 98 | |
| 93 | 99 | interface BufferedServerConfigEvent { |
| ... | ... | @@ -104,7 +110,11 @@ function serverConfigReplayEvents( |
| 104 | 110 | type: "snapshot" as const, |
| 105 | 111 | config: withoutEnvironmentThemes(state.projection.config), |
| 106 | 112 | }; |
| 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 | ]; | |
| 108 | 118 | } |
| 109 | 119 | |
| 110 | 120 | function mapSessionRpcError( |
| ... | ... | @@ -134,8 +144,10 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( |
| 134 | 144 | options: RpcSessionOptions = {}, |
| 135 | 145 | ) { |
| 136 | 146 | 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 | }; | |
| 139 | 151 | |
| 140 | 152 | const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { |
| 141 | 153 | yield* Effect.annotateCurrentSpan({ |
| ... | ... | @@ -218,6 +230,13 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( |
| 218 | 230 | event.config.environment.capabilities.environmentThemes !== true |
| 219 | 231 | ? undefined |
| 220 | 232 | : 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, | |
| 221 | 240 | } satisfies ServerConfigReplayState; |
| 222 | 241 | return [ |
| 223 | 242 | Option.some({ event, replay: next, revision: next.revision }), |